Compare commits

...

175 Commits

Author SHA1 Message Date
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
Jorge Reyes 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
Jason Ertel 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
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 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 Reyes 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 Reyes 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 Reyes 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 Patterson 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 Ertel 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 Reyes 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 Reyes 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 Reyes 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
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 Reeves 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
Jason Ertel 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 Patterson 9a70a06b3b Merge remote-tracking branch 'origin/3/dev' into jertel/wip 2026-05-28 13:55:12 -04:00
Mike Reeves 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 Reeves 68d783e760 Remove outdated HOTFIX version number 2026-05-28 10:24:47 -04:00
Mike Reeves 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 Reeves 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 Reeves 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 Reyes 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 Reyes 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
Jorge Reyes 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 Reeves 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 Reeves 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 Reeves 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 Reeves aa78978740 Add 3.2.0 option to discussion template 2026-05-21 15:57:57 -04:00
Mike Reeves 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 Reeves 89a28d2cfe Bump version from 3.1.0 to 3.2.0 2026-05-21 15:45:58 -04:00
Mike Reeves c1d187599b Merge pull request #15912 from Security-Onion-Solutions/3/dev
3.1.0
2026-05-21 15:41:50 -04:00
Mike Reeves 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 Reyes 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 Reyes 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
Jorge Reyes 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 Reyes e89c820b65 Revert "use -verify flag during grid agent install to ensure agent health" 2026-05-16 09:59:14 -05:00
Jorge Reyes 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 Ertel 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 Reeves 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 Reeves 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 Brower 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 Brower 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 Reyes 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 Reyes 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
reyesj2 d56bf01823 add zeek.ja4d ingest pipeline 2026-05-13 12:32:54 -05:00
Mike Reeves 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 Reeves 72327285b2 Change Telegraf output from BOTH to INFLUXDB 2026-05-13 11:58:21 -04:00
Josh Patterson 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 Patterson b068ad2b35 remove stig from hypervisor and managerhype 2026-05-13 10:53:11 -04:00
Jorge Reyes 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 Burks 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 Reyes 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 Brower 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 Reyes 4a2177c827 update redis index template
missing redis integration component templates
2026-05-11 16:15:56 -05:00
Josh Brower 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 Brower 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 Brower 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 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
Mike Reeves 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 Reyes 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 Reeves 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 Reeves 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 Reyes 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 Reyes 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
Jorge Reyes 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
reyesj2 39d0947102 update default elastic agent logging level to warning 2026-04-29 17:38:40 -05:00
Jorge Reyes 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 Reyes 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 Reeves 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
Mike Reeves 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
Mike Reeves 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 Reyes 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
reyesj2 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 Brower affede7f0a Rename 'ScanLNK' to 'ScanLnk' in YAML config 2026-04-20 10:01:10 -04:00
Josh Brower 97366c0496 Rename 'ScanLNK' to 'ScanLnk' in defaults.yaml 2026-04-20 10:00:29 -04:00
Marco Pedrinazzi d7e971a0fc m365 and fortigate mappings sigma 2026-03-11 14:49:40 +01:00
76 changed files with 2876 additions and 289 deletions
+1
View File
@@ -11,6 +11,7 @@ body:
- -
- 3.0.0 - 3.0.0
- 3.1.0 - 3.1.0
- 3.2.0
- Other (please provide detail below) - Other (please provide detail below)
validations: validations:
required: true required: true
+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 ### Download and Verify
3.0.0-20260331 ISO image: 3.1.0-20260528 ISO image:
https://download.securityonion.net/file/securityonion/securityonion-3.0.0-20260331.iso https://download.securityonion.net/file/securityonion/securityonion-3.1.0-20260528.iso
MD5: ECD318A1662A6FDE0EF213F5A9BD4B07 MD5: 9D6FF58DEEE24089D722C73169765B3E
SHA1: E55BE314440CCF3392DC0B06BC5E270B43176D9C SHA1: 2B8B816B6CEC3B7F96B3C5E040EBF502DD2C412F
SHA256: 7FC47405E335CBE5C2B6C51FE7AC60248F35CBE504907B8B5A33822B23F8F4D5 SHA256: 62FAB57E247C843D6A04F0796D8162C732B65D82FC3E4A59D087135B9FD32912
Signature for ISO image: 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: Signing key:
https://raw.githubusercontent.com/Security-Onion-Solutions/securityonion/3/main/KEYS 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: 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: 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: 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: 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: Good signature from "Security Onion Solutions, LLC <info@securityonionsolutions.com>"
gpg: WARNING: This key is not certified with a trusted signature! gpg: WARNING: This key is not certified with a trusted signature!
gpg: There is no indication that the signature belongs to the owner. 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
@@ -25,9 +25,11 @@ if [ ! -f $BACKUPFILE ]; then
# Create empty backup file # Create empty backup file
tar -cf $BACKUPFILE -T /dev/null 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 %} {%- for LOCATION in BACKUPLOCATIONS %}
if [[ -d {{ LOCATION }} || -f {{ LOCATION }} ]]; then
tar -rf $BACKUPFILE "${EXCLUSIONS[@]}" {{ LOCATION }} tar -rf $BACKUPFILE "${EXCLUSIONS[@]}" {{ LOCATION }}
fi
{%- endfor %} {%- endfor %}
fi fi
+11
View File
@@ -130,6 +130,17 @@ common_sbin:
- so-pcap-import - so-pcap-import
{% endif %} {% 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: common_sbin_jinja:
file.recurse: file.recurse:
- name: /usr/sbin - name: /usr/sbin
+15 -2
View File
@@ -192,8 +192,21 @@ update_docker_containers() {
echo "Unable to tag $image" >> "$LOG_FILE" 2>&1 echo "Unable to tag $image" >> "$LOG_FILE" 2>&1
exit 1 exit 1
} }
docker push $HOSTNAME:5000/$IMAGEREPO/$image >> "$LOG_FILE" 2>&1 || { # Push to the embedded registry via a registry-to-registry copy. Avoids
echo "Unable to push $image" >> "$LOG_FILE" 2>&1 # `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 exit 1
} }
fi fi
+3 -1
View File
@@ -165,6 +165,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 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|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|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 fi
if [[ $EXCLUDE_KNOWN_ERRORS == 'Y' ]]; then if [[ $EXCLUDE_KNOWN_ERRORS == 'Y' ]]; then
@@ -227,7 +229,7 @@ if [[ $EXCLUDE_KNOWN_ERRORS == 'Y' ]]; then
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|from NIC checksum offloading" # zeek reporter.log 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|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|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 EXCLUDED_ERRORS="$EXCLUDED_ERRORS|manifest unknown" # appears in so-dockerregistry log for so-tcpreplay following docker upgrade to 29.2.1-1
fi 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}"
@@ -51,6 +51,16 @@ so-elastic-fleet-package-registry:
- {{ ULIMIT.name }}={{ ULIMIT.soft }}:{{ ULIMIT.hard }} - {{ ULIMIT.name }}={{ ULIMIT.soft }}:{{ ULIMIT.hard }}
{% endfor %} {% endfor %}
{% endif %} {% 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: delete_so-elastic-fleet-package-registry_so-status.disabled:
file.uncomment: file.uncomment:
- name: /opt/so/conf/so-status/so-status.conf - name: /opt/so/conf/so-status/so-status.conf
+13
View File
@@ -26,7 +26,9 @@ include:
wait_for_elasticsearch_elasticfleet: wait_for_elasticsearch_elasticfleet:
cmd.run: cmd.run:
- name: so-elasticsearch-wait - name: so-elasticsearch-wait
{% endif %}
{% if GLOBALS.role == "so-fleet" %}
# Sync Elastic Agent artifacts to Fleet Node # Sync Elastic Agent artifacts to Fleet Node
elasticagent_syncartifacts: elasticagent_syncartifacts:
file.recurse: file.recurse:
@@ -99,6 +101,17 @@ so-elastic-fleet:
- file: trusttheca - file: trusttheca
- x509: etc_elasticfleet_key - x509: etc_elasticfleet_key
- x509: etc_elasticfleet_crt - 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 %} {% endif %}
delete_so-elastic-fleet_so-status.disabled: delete_so-elastic-fleet_so-status.disabled:
+25 -13
View File
@@ -9,26 +9,19 @@
include: include:
- elasticfleet.config - elasticfleet.config
- kibana.enabled
# If enabled, automatically update Fleet Logstash Outputs # 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: so-elastic-fleet-auto-configure-logstash-outputs:
cmd.run: cmd.run:
- name: /usr/sbin/so-elastic-fleet-outputs-update - name: /usr/sbin/so-elastic-fleet-outputs-update
- retry: - retry:
attempts: 4 attempts: 4
interval: 30 interval: 30
- require:
{# Separate from above in order to catch elasticfleet-logstash.crt changes and force update to fleet output policy #} - http: wait_for_so-kibana
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
{% endif %} {% endif %}
# If enabled, automatically update Fleet Server URLs & ES Connection # If enabled, automatically update Fleet Server URLs & ES Connection
@@ -38,6 +31,9 @@ so-elastic-fleet-auto-configure-server-urls:
- retry: - retry:
attempts: 4 attempts: 4
interval: 30 interval: 30
- require:
- http: wait_for_so-kibana
{% endif %}
# Automatically update Fleet Server Elasticsearch URLs & Agent Artifact URLs # Automatically update Fleet Server Elasticsearch URLs & Agent Artifact URLs
so-elastic-fleet-auto-configure-elasticsearch-urls: so-elastic-fleet-auto-configure-elasticsearch-urls:
@@ -46,6 +42,8 @@ so-elastic-fleet-auto-configure-elasticsearch-urls:
- retry: - retry:
attempts: 4 attempts: 4
interval: 30 interval: 30
- require:
- http: wait_for_so-kibana
so-elastic-fleet-auto-configure-artifact-urls: so-elastic-fleet-auto-configure-artifact-urls:
cmd.run: cmd.run:
@@ -53,6 +51,8 @@ so-elastic-fleet-auto-configure-artifact-urls:
- retry: - retry:
attempts: 4 attempts: 4
interval: 30 interval: 30
- require:
- http: wait_for_so-kibana
so-elastic-fleet-package-statefile: so-elastic-fleet-package-statefile:
file.managed: file.managed:
@@ -64,7 +64,9 @@ so-elastic-fleet-package-upgrade:
- name: /usr/sbin/so-elastic-fleet-package-upgrade - name: /usr/sbin/so-elastic-fleet-package-upgrade
- retry: - retry:
attempts: 3 attempts: 3
interval: 10 interval: 30
- require:
- http: wait_for_so-kibana
- onchanges: - onchanges:
- file: /opt/so/state/elastic_fleet_packages.txt - file: /opt/so/state/elastic_fleet_packages.txt
@@ -74,6 +76,8 @@ so-elastic-fleet-integrations:
- retry: - retry:
attempts: 3 attempts: 3
interval: 10 interval: 10
- require:
- http: wait_for_so-kibana
so-elastic-agent-grid-upgrade: so-elastic-agent-grid-upgrade:
cmd.run: cmd.run:
@@ -81,6 +85,8 @@ so-elastic-agent-grid-upgrade:
- retry: - retry:
attempts: 12 attempts: 12
interval: 5 interval: 5
- require:
- http: wait_for_so-kibana
so-elastic-fleet-integration-upgrade: so-elastic-fleet-integration-upgrade:
cmd.run: cmd.run:
@@ -88,16 +94,22 @@ so-elastic-fleet-integration-upgrade:
- retry: - retry:
attempts: 3 attempts: 3
interval: 10 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 #} {# Optional integrations script doesn't need the retries like so-elastic-fleet-integration-upgrade which loads the default integrations #}
so-elastic-fleet-addon-integrations: so-elastic-fleet-addon-integrations:
cmd.run: cmd.run:
- name: /usr/sbin/so-elastic-fleet-optional-integrations-load - name: /usr/sbin/so-elastic-fleet-optional-integrations-load
- require:
- http: wait_for_so-kibana
{% if ELASTICFLEETMERGED.config.defend_filters.enable_auto_configuration %} {% if ELASTICFLEETMERGED.config.defend_filters.enable_auto_configuration %}
so-elastic-defend-manage-filters-file-watch: so-elastic-defend-manage-filters-file-watch:
cmd.run: 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 - 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: - onchanges:
- file: elasticdefendcustom - file: elasticdefendcustom
- file: elasticdefenddisabled - file: elasticdefenddisabled
@@ -240,7 +240,7 @@ elastic_fleet_policy_create() {
--arg DESC "$DESC" \ --arg DESC "$DESC" \
--arg TIMEOUT $TIMEOUT \ --arg TIMEOUT $TIMEOUT \
--arg FLEETSERVER "$FLEETSERVER" \ --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 # Create Fleet Policy
if ! fleet_api "agent_policies" -XPOST -H 'kbn-xsrf: true' -H 'Content-Type: application/json' -d "$JSON_STRING"; then if ! fleet_api "agent_policies" -XPOST -H 'kbn-xsrf: true' -H 'Content-Type: application/json' -d "$JSON_STRING"; then
@@ -108,9 +108,12 @@ if [ ! -f /opt/so/state/eaintegrations.txt ]; then
done done
# Only create the state file if all policies were created/updated successfully # 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 touch /opt/so/state/eaintegrations.txt
else
exit 1
fi fi
else else
exit $RETURN_CODE echo "Fleet integration policies already loaded."
exit 0
fi fi
@@ -235,6 +235,16 @@ function update_kafka_outputs() {
{% endif %} {% 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 # Sort & hash the new list of Logstash Outputs
NEW_LIST_JSON=$(jq --compact-output --null-input '$ARGS.positional' --args -- "${NEW_LIST[@]}") NEW_LIST_JSON=$(jq --compact-output --null-input '$ARGS.positional' --args -- "${NEW_LIST[@]}")
NEW_HASH=$(sha256sum <<< "$NEW_LIST_JSON" | awk '{print $1}') NEW_HASH=$(sha256sum <<< "$NEW_LIST_JSON" | awk '{print $1}')
@@ -8,18 +8,33 @@
. /usr/sbin/so-elastic-fleet-common . /usr/sbin/so-elastic-fleet-common
PKG_LOAD_FAILURES=0
PKG_LOAD_FAILURES_NAMES=()
{%- for PACKAGE in SUPPORTED_PACKAGES %} {%- for PACKAGE in SUPPORTED_PACKAGES %}
echo "Upgrading {{ PACKAGE }} package..." echo "Upgrading {{ PACKAGE }} package..."
if VERSION=$(elastic_fleet_package_latest_version_check "{{ PACKAGE }}"); then if VERSION=$(elastic_fleet_package_latest_version_check "{{ PACKAGE }}"); then
if ! elastic_fleet_package_install "{{ PACKAGE }}" "$VERSION"; then if ! elastic_fleet_package_install "{{ PACKAGE }}" "$VERSION"; then
# exit 1 on failure to upgrade a default package, allow salt to handle retries PKG_LOAD_FAILURES=$((PKG_LOAD_FAILURES + 1))
echo -e "\nERROR: Failed to upgrade $PACKAGE to version: $VERSION" PKG_LOAD_FAILURES_NAMES+=("{{ PACKAGE }}")
exit 1
fi fi
else else
echo -e "\nERROR: Failed to get version information for integration $PACKAGE" PKG_LOAD_FAILURES=$((PKG_LOAD_FAILURES + 1))
PKG_LOAD_FAILURES_NAMES+=("{{ PACKAGE }}")
fi fi
echo echo
{%- endfor %} {%- endfor %}
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
echo echo
/usr/sbin/so-elasticsearch-templates-load /usr/sbin/so-elasticsearch-templates-load
+18 -1
View File
@@ -9,9 +9,12 @@
{% from 'elasticsearch/config.map.jinja' import ELASTICSEARCHMERGED %} {% from 'elasticsearch/config.map.jinja' import ELASTICSEARCHMERGED %}
{% from 'elasticsearch/template.map.jinja' import ES_INDEX_SETTINGS, SO_MANAGED_INDICES %} {% from 'elasticsearch/template.map.jinja' import ES_INDEX_SETTINGS, SO_MANAGED_INDICES %}
{% if GLOBALS.role != 'so-heavynode' %} {% 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 %} {% endif %}
include:
- elasticsearch.enabled
escomponenttemplates: escomponenttemplates:
file.recurse: file.recurse:
- name: /opt/so/conf/elasticsearch/templates/component - name: /opt/so/conf/elasticsearch/templates/component
@@ -35,6 +38,20 @@ so_index_template_dir:
{%- endfor %} {%- endfor %}
{%- endif %} {%- 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) # 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 # These index templates are for the core SO datasets and are always required
{% for index, settings in ES_INDEX_SETTINGS.items() %} {% for index, settings in ES_INDEX_SETTINGS.items() %}
+4 -1
View File
@@ -3958,10 +3958,13 @@ elasticsearch:
- vulnerability-mappings - vulnerability-mappings
- common-settings - common-settings
- common-dynamic-mappings - common-dynamic-mappings
- logs-redis.log@package
- logs-redis.log@custom
data_stream: data_stream:
allow_custom_routing: false allow_custom_routing: false
hidden: false hidden: false
ignore_missing_component_templates: [] ignore_missing_component_templates:
- logs-redis.log@custom
index_patterns: index_patterns:
- logs-redis.log* - logs-redis.log*
priority: 501 priority: 501
+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}}" } }, { "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" } }, { "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}}" } }, { "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 } }, { "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 } }, { "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" } } { "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" "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": { "pipeline": {
"name": ".fleet_final_pipeline-1", "name": ".fleet_final_pipeline-1",
"ignore_missing_pipeline": true "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": { "remove": {
"field": "event.agent_id_status", "field": "event.agent_id_status",
@@ -202,7 +274,8 @@
"event.dataset_temp", "event.dataset_temp",
"dataset_tag_temp", "dataset_tag_temp",
"module_temp", "module_temp",
"datastream_dataset_temp" "datastream_dataset_temp",
"_tmp"
], ],
"ignore_missing": true, "ignore_missing": true,
"ignore_failure": true "ignore_failure": true
+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"
}
}
]
}
+22 -3
View File
@@ -61,15 +61,25 @@
{% if ALL_ADDON_SETTINGS_ORIG.keys() | length > 0 %} {% if ALL_ADDON_SETTINGS_ORIG.keys() | length > 0 %}
{% for index in ALL_ADDON_SETTINGS_ORIG.keys() %} {% 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)}) %} {% 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 %} {% endfor %}
{% endif %} {% endif %}
{% set ES_INDEX_SETTINGS = {} %} {% 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)) %} {% do GLOBAL_OVERRIDES.update(salt['defaults.merge'](GLOBAL_OVERRIDES, ES_INDEX_PILLAR, in_place=False)) %}
{% for index, settings in GLOBAL_OVERRIDES.items() %} {% for index, settings in GLOBAL_OVERRIDES.items() %}
{% if index in EXCLUDE_INDICES %}
{% continue %}
{% endif %}
{# prevent this action from being performed on custom defined indices. #} {# 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. #} {# 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 %} {% if index in DEFINED_SETTINGS and index in GLOBAL_OVERRIDES %}
@@ -150,10 +160,19 @@
{% endfor %} {% endfor %}
{% endmacro %} {% endmacro %}
{{ create_final_index_template(ES_INDEX_SETTINGS_ORIG, ES_INDEX_SETTINGS_GLOBAL_OVERRIDES, ES_INDEX_SETTINGS) }} {# Exclude addon integrations from final ES_INDEX_SETTINGS #}
{{ create_final_index_template(ALL_ADDON_SETTINGS_ORIG, ALL_ADDON_SETTINGS_GLOBAL_OVERRIDES, ALL_ADDON_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 = [] %} {% set SO_MANAGED_INDICES = [] %}
{% for index, settings in ES_INDEX_SETTINGS.items() %} {% for index, settings in ES_INDEX_SETTINGS.items() %}
{% do SO_MANAGED_INDICES.append(index) %} {% do SO_MANAGED_INDICES.append(index) %}
{% endfor %} {% endfor %}
{% set ADDON_INDICES = [] %}
{% for index, settings in ALL_ADDON_SETTINGS.items() %}
{% do ADDON_INDICES.append(index) %}
{% endfor %}
+33
View File
@@ -398,6 +398,7 @@ firewall:
- elasticsearch_rest - elasticsearch_rest
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- sensoroni - sensoroni
- yum - yum
- beats_5044 - beats_5044
@@ -410,6 +411,7 @@ firewall:
portgroups: portgroups:
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- sensoroni - sensoroni
- yum - yum
- beats_5044 - beats_5044
@@ -427,6 +429,7 @@ firewall:
- yum - yum
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- sensoroni - sensoroni
searchnode: searchnode:
portgroups: portgroups:
@@ -437,6 +440,7 @@ firewall:
- yum - yum
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- elastic_agent_control - elastic_agent_control
- elastic_agent_data - elastic_agent_data
- elastic_agent_update - elastic_agent_update
@@ -450,6 +454,7 @@ firewall:
- yum - yum
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- elastic_agent_control - elastic_agent_control
- elastic_agent_data - elastic_agent_data
- elastic_agent_update - elastic_agent_update
@@ -459,6 +464,7 @@ firewall:
- yum - yum
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- elastic_agent_control - elastic_agent_control
- elastic_agent_data - elastic_agent_data
- elastic_agent_update - elastic_agent_update
@@ -492,6 +498,7 @@ firewall:
portgroups: portgroups:
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- sensoroni - sensoroni
- yum - yum
- elastic_agent_control - elastic_agent_control
@@ -502,6 +509,7 @@ firewall:
- yum - yum
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- elastic_agent_control - elastic_agent_control
- elastic_agent_data - elastic_agent_data
- elastic_agent_update - elastic_agent_update
@@ -610,6 +618,7 @@ firewall:
- elasticsearch_rest - elasticsearch_rest
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- sensoroni - sensoroni
- yum - yum
- beats_5044 - beats_5044
@@ -622,6 +631,7 @@ firewall:
portgroups: portgroups:
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- sensoroni - sensoroni
- yum - yum
- beats_5044 - beats_5044
@@ -639,6 +649,7 @@ firewall:
- yum - yum
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- sensoroni - sensoroni
searchnode: searchnode:
portgroups: portgroups:
@@ -649,6 +660,7 @@ firewall:
- yum - yum
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- elastic_agent_control - elastic_agent_control
- elastic_agent_data - elastic_agent_data
- elastic_agent_update - elastic_agent_update
@@ -662,6 +674,7 @@ firewall:
- yum - yum
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- elastic_agent_control - elastic_agent_control
- elastic_agent_data - elastic_agent_data
- elastic_agent_update - elastic_agent_update
@@ -671,6 +684,7 @@ firewall:
- yum - yum
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- elastic_agent_control - elastic_agent_control
- elastic_agent_data - elastic_agent_data
- elastic_agent_update - elastic_agent_update
@@ -702,6 +716,7 @@ firewall:
portgroups: portgroups:
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- sensoroni - sensoroni
- yum - yum
- elastic_agent_control - elastic_agent_control
@@ -712,6 +727,7 @@ firewall:
- yum - yum
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- elastic_agent_control - elastic_agent_control
- elastic_agent_data - elastic_agent_data
- elastic_agent_update - elastic_agent_update
@@ -820,6 +836,7 @@ firewall:
- elasticsearch_rest - elasticsearch_rest
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- sensoroni - sensoroni
- yum - yum
- beats_5044 - beats_5044
@@ -832,6 +849,7 @@ firewall:
portgroups: portgroups:
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- sensoroni - sensoroni
- yum - yum
- beats_5044 - beats_5044
@@ -849,6 +867,7 @@ firewall:
- yum - yum
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- sensoroni - sensoroni
searchnode: searchnode:
portgroups: portgroups:
@@ -858,6 +877,7 @@ firewall:
- yum - yum
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- elastic_agent_control - elastic_agent_control
- elastic_agent_data - elastic_agent_data
- elastic_agent_update - elastic_agent_update
@@ -870,6 +890,7 @@ firewall:
- yum - yum
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- elastic_agent_control - elastic_agent_control
- elastic_agent_data - elastic_agent_data
- elastic_agent_update - elastic_agent_update
@@ -879,6 +900,7 @@ firewall:
- yum - yum
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- elastic_agent_control - elastic_agent_control
- elastic_agent_data - elastic_agent_data
- elastic_agent_update - elastic_agent_update
@@ -912,6 +934,7 @@ firewall:
portgroups: portgroups:
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- sensoroni - sensoroni
- yum - yum
- elastic_agent_control - elastic_agent_control
@@ -922,6 +945,7 @@ firewall:
- yum - yum
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- elastic_agent_control - elastic_agent_control
- elastic_agent_data - elastic_agent_data
- elastic_agent_update - elastic_agent_update
@@ -1040,6 +1064,7 @@ firewall:
- elasticsearch_rest - elasticsearch_rest
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- sensoroni - sensoroni
- yum - yum
- beats_5044 - beats_5044
@@ -1052,6 +1077,7 @@ firewall:
portgroups: portgroups:
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- sensoroni - sensoroni
- yum - yum
- beats_5044 - beats_5044
@@ -1063,6 +1089,7 @@ firewall:
portgroups: portgroups:
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- sensoroni - sensoroni
- yum - yum
- beats_5044 - beats_5044
@@ -1074,6 +1101,7 @@ firewall:
portgroups: portgroups:
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- sensoroni - sensoroni
- yum - yum
- redis - redis
@@ -1083,6 +1111,7 @@ firewall:
portgroups: portgroups:
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- sensoroni - sensoroni
- yum - yum
- redis - redis
@@ -1093,6 +1122,7 @@ firewall:
- yum - yum
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- elastic_agent_control - elastic_agent_control
- elastic_agent_data - elastic_agent_data
- elastic_agent_update - elastic_agent_update
@@ -1129,6 +1159,7 @@ firewall:
portgroups: portgroups:
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- sensoroni - sensoroni
- yum - yum
- elastic_agent_control - elastic_agent_control
@@ -1139,6 +1170,7 @@ firewall:
- yum - yum
- docker_registry - docker_registry
- influxdb - influxdb
- postgres
- elastic_agent_control - elastic_agent_control
- elastic_agent_data - elastic_agent_data
- elastic_agent_update - elastic_agent_update
@@ -1482,6 +1514,7 @@ firewall:
- kibana - kibana
- redis - redis
- influxdb - influxdb
- postgres
- elasticsearch_rest - elasticsearch_rest
- elasticsearch_node - elasticsearch_node
- elastic_agent_control - elastic_agent_control
-13
View File
@@ -1,6 +1,5 @@
{% from 'vars/globals.map.jinja' import GLOBALS %} {% from 'vars/globals.map.jinja' import GLOBALS %}
{% from 'docker/docker.map.jinja' import DOCKERMERGED %} {% from 'docker/docker.map.jinja' import DOCKERMERGED %}
{% from 'telegraf/map.jinja' import TELEGRAFMERGED %}
{% import_yaml 'firewall/defaults.yaml' as FIREWALL_DEFAULT %} {% import_yaml 'firewall/defaults.yaml' as FIREWALL_DEFAULT %}
{# add our ip to self #} {# add our ip to self #}
@@ -56,16 +55,4 @@
{% endif %} {% 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) %} {% set FIREWALL_MERGED = salt['pillar.get']('firewall', FIREWALL_DEFAULT.firewall, merge=True) %}
+14
View File
@@ -6,6 +6,7 @@
{% from 'allowed_states.map.jinja' import allowed_states %} {% from 'allowed_states.map.jinja' import allowed_states %}
{% if sls.split('.')[0] in allowed_states %} {% if sls.split('.')[0] in allowed_states %}
{% from 'docker/docker.map.jinja' import DOCKERMERGED %} {% from 'docker/docker.map.jinja' import DOCKERMERGED %}
{% from 'elasticsearch/config.map.jinja' import ELASTICSEARCHMERGED %}
{% from 'vars/globals.map.jinja' import GLOBALS %} {% from 'vars/globals.map.jinja' import GLOBALS %}
include: include:
@@ -60,6 +61,19 @@ so-kibana:
- watch: - watch:
- file: kibanaconfig - 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: 300
- request_interval: 15
- require:
- docker_container: so-kibana
delete_so-kibana_so-status.disabled: delete_so-kibana_so-status.disabled:
file.uncomment: file.uncomment:
- name: /opt/so/conf/so-status/so-status.conf - name: /opt/so/conf/so-status/so-status.conf
+1 -1
View File
@@ -103,7 +103,7 @@ kratos:
config: config:
session: session:
lifespan: 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 global: True
helpLink: kratos helpLink: kratos
whoami: whoami:
+3 -2
View File
@@ -26,12 +26,12 @@ logstash:
manager: manager:
- so/0011_input_endgame.conf - so/0011_input_endgame.conf
- so/0012_input_elastic_agent.conf.jinja - 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 - so/9999_output_redis.conf.jinja
receiver: receiver:
- so/0011_input_endgame.conf - so/0011_input_endgame.conf
- so/0012_input_elastic_agent.conf.jinja - 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 - so/9999_output_redis.conf.jinja
search: search:
- so/0900_input_redis.conf.jinja - so/0900_input_redis.conf.jinja
@@ -69,4 +69,5 @@ logstash:
pipeline_x_batch_x_size: 125 pipeline_x_batch_x_size: 125
pipeline_x_ecs_compatibility: disabled pipeline_x_ecs_compatibility: disabled
dmz_nodes: [] dmz_nodes: []
latency_metrics: False
@@ -1,3 +1,4 @@
{%- from 'logstash/map.jinja' import LOGSTASH_MERGED %}
input { input {
elastic_agent { elastic_agent {
port => 5055 port => 5055
@@ -11,6 +12,11 @@ input {
} }
} }
filter { filter {
{% if LOGSTASH_MERGED.get('latency_metrics', False) %}
ruby {
code => "event.set('[_tmp][logstash_from_agent]', Time.now().utc.iso8601(3));"
}
{% endif %}
if ![metadata] { if ![metadata] {
mutate { mutate {
rename => {"@metadata" => "metadata"} 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_password = salt['pillar.get']('kafka:config:password') %}
{%- set kafka_trustpass = salt['pillar.get']('kafka:config:trustpass') %} {%- set kafka_trustpass = salt['pillar.get']('kafka:config:trustpass') %}
{%- set kafka_brokers = salt['pillar.get']('kafka:nodes', {}) %} {%- set kafka_brokers = salt['pillar.get']('kafka:nodes', {}) %}
@@ -30,6 +31,11 @@ input {
} }
} }
filter { filter {
{% if LOGSTASH_MERGED.get('latency_metrics', False) %}
ruby {
code => "event.set('[_tmp][logstash_from_kafka]', Time.now().utc.iso8601(3));"
}
{% endif %}
if ![metadata] { if ![metadata] {
mutate { mutate {
rename => { "@metadata" => "metadata" } 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') %} {%- set REDIS_PASS = salt['pillar.get']('redis:config:requirepass') %}
{%- for index in range(LOGSTASH_REDIS_NODES|length) %} {%- for index in range(LOGSTASH_REDIS_NODES|length) %}
@@ -18,3 +18,10 @@ input {
} }
{% endfor %} {% endfor %}
{% 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 { output {
if "elastic-agent" in [tags] and "so-ip-mappings" in [tags] { if "elastic-agent" in [tags] and "so-ip-mappings" in [tags] {
elasticsearch { elasticsearch {
@@ -13,7 +13,14 @@ filter {
add_tag => "fleet-lumberjack-{{ GLOBALS.hostname }}" 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 { output {
lumberjack { lumberjack {
codec => json codec => json
@@ -1,10 +1,17 @@
{%- from 'logstash/map.jinja' import LOGSTASH_MERGED %}
{%- if grains.role in ['so-heavynode', 'so-receiver'] %} {%- if grains.role in ['so-heavynode', 'so-receiver'] %}
{%- set HOST = GLOBALS.hostname %} {%- set HOST = GLOBALS.hostname %}
{%- else %} {%- else %}
{%- set HOST = GLOBALS.manager %} {%- set HOST = GLOBALS.manager %}
{%- endif %} {%- endif %}
{%- set REDIS_PASS = salt['pillar.get']('redis:config:requirepass') %} {%- 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 { output {
redis { redis {
host => '{{ HOST }}' host => '{{ HOST }}'
+5
View File
@@ -86,3 +86,8 @@ logstash:
multiline: True multiline: True
advanced: True advanced: True
forcedType: "[]string" 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
+5 -3
View File
@@ -31,11 +31,13 @@ sync_es_users:
- http: wait_for_kratos - http: wait_for_kratos
- file: so-user.lock # require so-user.lock file to be missing - 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' # we dont want this added too early in setup, so the onlyif gates on the
# is in the minion config. That line is added before the final highstate during setup # /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: so-user_sync:
cron.present: cron.present:
- user: root - 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' - 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 - 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()
+424 -18
View File
@@ -188,13 +188,6 @@ airgap_update_dockers() {
fi fi
} }
backup_old_states_pillars() {
tar czf /nsm/backup/$(echo $INSTALLEDVERSION)_$(date +%Y%m%d-%H%M%S)_soup_default_states_pillars.tar.gz /opt/so/saltstack/default/
tar czf /nsm/backup/$(echo $INSTALLEDVERSION)_$(date +%Y%m%d-%H%M%S)_soup_local_states_pillars.tar.gz /opt/so/saltstack/local/
}
update_registry() { update_registry() {
docker stop so-dockerregistry docker stop so-dockerregistry
docker rm so-dockerregistry docker rm so-dockerregistry
@@ -372,6 +365,7 @@ preupgrade_changes() {
[[ "$INSTALLEDVERSION" =~ ^2\.4\.21[0-9]+$ ]] && up_to_3.0.0 [[ "$INSTALLEDVERSION" =~ ^2\.4\.21[0-9]+$ ]] && up_to_3.0.0
[[ "$INSTALLEDVERSION" == "3.0.0" ]] && up_to_3.1.0 [[ "$INSTALLEDVERSION" == "3.0.0" ]] && up_to_3.1.0
[[ "$INSTALLEDVERSION" == "3.1.0" ]] && up_to_3.2.0
true true
} }
@@ -381,6 +375,7 @@ postupgrade_changes() {
[[ "$POSTVERSION" =~ ^2\.4\.21[0-9]+$ ]] && post_to_3.0.0 [[ "$POSTVERSION" =~ ^2\.4\.21[0-9]+$ ]] && post_to_3.0.0
[[ "$POSTVERSION" == "3.0.0" ]] && post_to_3.1.0 [[ "$POSTVERSION" == "3.0.0" ]] && post_to_3.1.0
[[ "$POSTVERSION" == "3.1.0" ]] && post_to_3.2.0
true true
} }
@@ -485,6 +480,158 @@ elasticsearch_backup_index_templates() {
tar -czf /nsm/backup/3.0.0_elasticsearch_index_templates.tar.gz -C /opt/so/conf/elasticsearch/templates/index/ . tar -czf /nsm/backup/3.0.0_elasticsearch_index_templates.tar.gz -C /opt/so/conf/elasticsearch/templates/index/ .
} }
elasticfleet_set_agent_logging_level_warn() {
. /usr/sbin/so-elastic-fleet-common
local current_agent_policies
if ! current_agent_policies=$(fleet_api "agent_policies?perPage=1000"); then
echo "Warning: unable to retrieve Fleet agent policies"
return 0
fi
# Only updating policies that are within Security Onion defaults and do not already have any user configured advanced_settings.
local policies_to_update
policies_to_update=$(jq -c '
.items[]
| select(has("advanced_settings") | not)
| select(
.id == "so-grid-nodes_general"
or .id == "so-grid-nodes_heavy"
or .id == "endpoints-initial"
or (.id | startswith("FleetServer_"))
)
' <<< "$current_agent_policies")
if [[ -z "$policies_to_update" ]]; then
return 0
fi
while IFS= read -r policy; do
[[ -z "$policy" ]] && continue
local policy_id policy_name policy_namespace
policy_id=$(jq -r '.id' <<< "$policy")
policy_name=$(jq -r '.name' <<< "$policy")
policy_namespace=$(jq -r '.namespace' <<< "$policy")
local update_logging
update_logging=$(jq -n \
--arg name "$policy_name" \
--arg namespace "$policy_namespace" \
'{name: $name, namespace: $namespace, advanced_settings: {agent_logging_level: "warning"}}'
)
echo "Setting elastic agent_logging_level to warning on policy '$policy_name' ($policy_id)."
if ! fleet_api "agent_policies/$policy_id" -XPUT -H 'kbn-xsrf: true' -H 'Content-Type: application/json' -d "$update_logging" >/dev/null; then
echo " warning: failed to update agent policy '$policy_name' ($policy_id)" >&2
fi
done <<< "$policies_to_update"
}
update_logstash_pipeline_name() {
local original_pipeline_name="$1"
local new_pipeline_name="$2"
echo "Checking for conflicting logstash defined_pipelines pillar value."
local LOGSTASH_FILE=/opt/so/saltstack/local/pillar/logstash/soc_logstash.sls
local MINIONDIR=/opt/so/saltstack/local/pillar/minions
for pillar_file in "$LOGSTASH_FILE" "$MINIONDIR"/*.sls; do
[[ -f "$pillar_file" ]] || continue
if grep -q "$original_pipeline_name$" "$pillar_file"; then
echo "Found conflicting defined_pipeline pillar value in $pillar_file. Updating to use the new logstash pipeline name."
sed -i "s#$original_pipeline_name\$#$new_pipeline_name#g" "$pillar_file"
chown socore:socore "$pillar_file"
fi
done
}
check_transform_health_and_reauthorize() {
. /usr/sbin/so-elastic-fleet-common
echo "Checking integration transform jobs for unhealthy / unauthorized status..."
local transforms_doc stats_doc installed_doc
if ! transforms_doc=$(so-elasticsearch-query "_transform/_all?size=1000" --fail --retry 3 --retry-delay 5 2>/dev/null); then
echo "Unable to query for transform jobs, skipping reauthorization."
return 0
fi
if ! stats_doc=$(so-elasticsearch-query "_transform/_all/_stats?size=1000" --fail --retry 3 --retry-delay 5 2>/dev/null); then
echo "Unable to query for transform job stats, skipping reauthorization."
return 0
fi
if ! installed_doc=$(fleet_api "epm/packages/installed?perPage=500"); then
echo "Unable to list installed Fleet packages, skipping reauthorization."
return 0
fi
# Get all transforms that meet the following
# - unhealthy (any non-green health status)
# - metadata has run_as_kibana_system: false (this fix is specific to transforms started prior to Kibana 9.3.3)
# - are not orphaned (integration is not somehow missing/corrupt/uninstalled)
local tmp_transforms tmp_stats tmp_installed
tmp_transforms=$(mktemp)
tmp_stats=$(mktemp)
tmp_installed=$(mktemp)
echo "$transforms_doc" > "$tmp_transforms"
echo "$stats_doc" > "$tmp_stats"
echo "$installed_doc" > "$tmp_installed"
local unhealthy_transforms
unhealthy_transforms=$(jq -c -n \
--slurpfile t "$tmp_transforms" \
--slurpfile s "$tmp_stats" \
--slurpfile i "$tmp_installed" '
($i[0].items | map({key: .name, value: .version}) | from_entries) as $pkg_ver
| ($s[0].transforms | map({key: .id, value: .health.status}) | from_entries) as $health
| [ $t[0].transforms[]
| select(._meta.run_as_kibana_system == false)
| select(($health[.id] // "unknown") != "green")
| {id, pkg: ._meta.package.name, ver: ($pkg_ver[._meta.package.name])}
]
| if length == 0 then empty else . end
| (map(select(.ver == null)) | map({orphan: .id})[]),
(map(select(.ver != null))
| group_by(.pkg)
| map({pkg: .[0].pkg, ver: .[0].ver, transformIds: map(.id)})[])
')
if [[ -z "$unhealthy_transforms" ]]; then
return 0
fi
local unhealthy_count
unhealthy_count=$(jq -s '[.[].transformIds? // empty | .[]] | length' <<< "$unhealthy_transforms")
echo "Found $unhealthy_count transform(s) needing reauthorization."
local total_failures=0
while IFS= read -r transform; do
[[ -z "$transform" ]] && continue
if jq -e 'has("orphan")' <<< "$transform" >/dev/null 2>&1; then
echo "Skipping transform not owned by any installed Fleet package: $(jq -r '.orphan' <<< "$transform")"
continue
fi
local pkg ver body resp
pkg=$(jq -r '.pkg' <<< "$transform")
ver=$(jq -r '.ver' <<< "$transform")
body=$(jq -c '{transforms: (.transformIds | map({transformId: .}))}' <<< "$transform")
echo "Reauthorizing transform(s) for ${pkg}-${ver}..."
resp=$(fleet_api "epm/packages/${pkg}/${ver}/transforms/authorize" \
-XPOST -H 'kbn-xsrf: true' -H 'Content-Type: application/json' \
-d "$body") || { echo "Could not reauthorize transform(s) for ${pkg}-${ver}"; continue; }
(( total_failures += $(jq 'map(select(.success != true)) | length' <<< "$resp" 2>/dev/null) ))
done <<< "$unhealthy_transforms"
rm -f "$tmp_transforms" "$tmp_stats" "$tmp_installed"
if [[ "$total_failures" -gt 0 ]]; then
echo "Some transform(s) failed to reauthorize."
fi
}
ensure_postgres_local_pillar() { ensure_postgres_local_pillar() {
# Postgres was added as a service after 3.0.0, so the new pillar/top.sls # Postgres was added as a service after 3.0.0, so the new pillar/top.sls
# references postgres.soc_postgres / postgres.adv_postgres unconditionally. # references postgres.soc_postgres / postgres.adv_postgres unconditionally.
@@ -520,6 +667,31 @@ ensure_postgres_secret() {
chown socore:socore "$secrets_file" chown socore:socore "$secrets_file"
} }
rename_strelka_scan_lnk() {
echo "Renaming strelka pillar ScanLNK to ScanLnk."
local STRELKA_FILE=/opt/so/saltstack/local/pillar/strelka/soc_strelka.sls
local MINIONDIR=/opt/so/saltstack/local/pillar/minions
local OLD_KEY=strelka.backend.config.backend.scanners.ScanLNK
local NEW_KEY=strelka.backend.config.backend.scanners.ScanLnk
local TMP_VALUE_FILE
TMP_VALUE_FILE=$(mktemp)
for pillar_file in "$STRELKA_FILE" "$MINIONDIR"/*.sls; do
[[ -f "$pillar_file" ]] || continue
# Skip if ScanLNK doesn't exist
so-yaml.py get "$pillar_file" "$OLD_KEY" > "$TMP_VALUE_FILE" 2>/dev/null || continue
echo "Found 'ScanLNK' key in $pillar_file. Renaming to 'ScanLnk'."
so-yaml.py add "$pillar_file" "$NEW_KEY" "file:$TMP_VALUE_FILE"
so-yaml.py remove "$pillar_file" "$OLD_KEY"
done
rm -f "$TMP_VALUE_FILE"
}
fix_logstash_0013_lumberjack_pipeline_name() {
update_logstash_pipeline_name "so/0013_input_lumberjack_fleet.conf" "so/0013_input_lumberjack_fleet.conf.jinja"
}
up_to_3.1.0() { up_to_3.1.0() {
ensure_postgres_local_pillar ensure_postgres_local_pillar
ensure_postgres_secret ensure_postgres_secret
@@ -527,7 +699,8 @@ up_to_3.1.0() {
elasticsearch_backup_index_templates elasticsearch_backup_index_templates
# Clear existing component template state file. # Clear existing component template state file.
rm -f /opt/so/state/esfleet_component_templates.json rm -f /opt/so/state/esfleet_component_templates.json
rename_strelka_scan_lnk
fix_logstash_0013_lumberjack_pipeline_name
INSTALLEDVERSION=3.1.0 INSTALLEDVERSION=3.1.0
} }
@@ -553,11 +726,59 @@ post_to_3.1.0() {
# file_roots of its own and --local would fail with "No matching sls found". # file_roots of its own and --local would fail with "No matching sls found".
salt-call state.apply postgres.telegraf_users queue=True || true salt-call state.apply postgres.telegraf_users queue=True || true
# Update default agent policies to use logging level warn.
elasticfleet_set_agent_logging_level_warn || true
# Check for unhealthy / unauthorized integration transform jobs and attempt reauthorizations
check_transform_health_and_reauthorize || true
POSTVERSION=3.1.0 POSTVERSION=3.1.0
} }
### 3.1.0 End ### ### 3.1.0 End ###
### 3.2.0 Scripts ###
bootstrap_so_soc_database() {
# init-db.sh is mounted into so-postgres at /docker-entrypoint-initdb.d/init-db.sh
# and runs automatically only on a fresh data directory. Hosts upgrading from
# 3.1.0 already have /nsm/postgres populated, so the so_soc bootstrap block
# added in 3.2 never fires. Re-run the script explicitly; it's idempotent.
echo "Bootstrapping so_soc database via init-db.sh."
# The postgres image has no USER directive, so `docker exec` defaults to
# root, and the container env intentionally omits POSTGRES_USER (the upstream
# entrypoint defaults it transiently during first-init only). Recreate both
# so psql inside init-db.sh resolves the connect user correctly.
local exec_cmd="docker exec -u postgres -e POSTGRES_USER=postgres so-postgres bash /docker-entrypoint-initdb.d/init-db.sh"
if ! /usr/sbin/so-postgres-wait; then
FINAL_MESSAGE_QUEUE+=("WARNING: so-postgres was not ready during the 3.2.0 upgrade; the so_soc database may not have been bootstrapped. Re-run manually: $exec_cmd")
return 0
fi
if ! $exec_cmd; then
FINAL_MESSAGE_QUEUE+=("WARNING: init-db.sh failed inside so-postgres during the 3.2.0 upgrade; the so_soc database may not have been bootstrapped. Re-run manually: $exec_cmd")
return 0
fi
echo "so_soc bootstrap complete."
}
up_to_3.2.0() {
fix_logstash_0013_lumberjack_pipeline_name
INSTALLEDVERSION=3.2.0
}
post_to_3.2.0() {
bootstrap_so_soc_database
# Including agent regen script here since it was missed in post_to_3.1.0
echo "Regenerating Elastic Agent Installers"
/sbin/so-elastic-agent-gen-installers
POSTVERSION=3.2.0
}
### 3.2.0 End ###
repo_sync() { repo_sync() {
echo "Sync the local repo." echo "Sync the local repo."
@@ -809,6 +1030,9 @@ verify_es_version_compatibility() {
local is_active_intermediate_upgrade=1 local is_active_intermediate_upgrade=1
# supported upgrade paths for SO-ES versions # supported upgrade paths for SO-ES versions
declare -A es_upgrade_map=( declare -A es_upgrade_map=(
["8.18.4"]="8.18.6 8.18.8 9.0.8"
["8.18.6"]="8.18.8 9.0.8"
["8.18.8"]="9.0.8"
["9.0.8"]="9.3.3" ["9.0.8"]="9.3.3"
) )
@@ -832,6 +1056,171 @@ verify_es_version_compatibility() {
exit 160 exit 160
fi fi
compatible_es_versions="$target_es_version"
for current_version in "${!es_upgrade_map[@]}"; do
# shellcheck disable=SC2076
if [[ " ${es_upgrade_map[$current_version]} " =~ " $target_es_version " ]]; then
compatible_es_versions+=" $current_version"
fi
done
# Check if the given ES version can directly upgrade to the target ES version. Used to assist with catching lagging nodes during the upgrade process
es_version_can_upgrade_to_target() {
local current_version="$1"
# shellcheck disable=SC2076
if [[ -n "$current_version" && " $compatible_es_versions " =~ " $current_version " ]]; then
return 0
fi
return 1
}
# Gather Elasticsearch cluster version info and verify that each node in the cluster is running a version compatible with the target ES version.
verify_searchnodes_es_target_compatibility() {
local retries=20
local retry_count=0
local delay=180
local expected_es_nodes searchnode_minions attempt
local searchnode_discovery_success=false
SEARCHNODE_ES_VERSIONS=""
for attempt in {1..3}; do
if searchnode_minions=$(set -o pipefail; salt-key --out=json --list=accepted 2> /dev/null | jq -r '.minions[]? | select(endswith("searchnode"))'); then
searchnode_discovery_success=true
break
fi
echo "Failed to retrieve grid searchnodes via salt-key... Retrying in 30 seconds. Attempt $attempt of 3."
sleep 30
done
if [[ "$searchnode_discovery_success" != "true" ]]; then
echo "Failed to retrieve grid searchnodes via salt-key."
return 1
fi
# Always add node running soup to expected es nodes
expected_es_nodes="${MINIONID%_*}"
while IFS= read -r searchnode_minion; do
[[ -z "$searchnode_minion" ]] && continue
expected_es_nodes+=$'\n'"${searchnode_minion%_searchnode}"
done <<< "$searchnode_minions"
while [[ $retry_count -lt $retries ]]; do
SEARCHNODE_ES_VERSIONS=$(so-elasticsearch-query _nodes/_all/version --retry 5 --retry-delay 10 --fail 2>&1)
local exit_status=$?
if [[ $exit_status -ne 0 ]]; then
echo "Failed to retrieve Elasticsearch versions from searchnodes... Retrying in $delay seconds. Attempt $((retry_count + 1)) of $retries."
((retry_count++))
sleep $delay
continue
fi
local all_searchnodes_compatible=true
while IFS=$'\t' read -r node current_version; do
[[ -z "$node" ]] && continue
if ! es_version_can_upgrade_to_target "$current_version"; then
echo "Searchnode $node is running Elasticsearch $current_version, which is not directly upgradable to Elasticsearch $target_es_version."
all_searchnodes_compatible=false
fi
done < <(echo "$SEARCHNODE_ES_VERSIONS" | jq -r '.nodes | to_entries[] | [.value.name, .value.version] | @tsv')
while IFS= read -r expected_es_node; do
[[ -z "$expected_es_node" ]] && continue
if ! echo "$SEARCHNODE_ES_VERSIONS" | jq -e --arg node "$expected_es_node" '.nodes | to_entries | any(.value.name == $node)' > /dev/null; then
echo "Searchnode $expected_es_node did not report an Elasticsearch version. It may be offline or still upgrading."
all_searchnodes_compatible=false
fi
done <<< "$expected_es_nodes"
if [[ "$all_searchnodes_compatible" == true ]]; then
echo "All Searchnodes are upgradable to Elasticsearch $target_es_version."
return 0
fi
echo "One or more Searchnodes cannot upgrade directly to Elasticsearch $target_es_version. Rechecking in $delay seconds. Attempt $((retry_count + 1)) of $retries."
((retry_count++))
sleep $delay
done
return 1
}
# Gather heavynode version info and verify that each node is running a version compatible with the target ES version.
verify_heavynodes_es_target_compatibility() {
local heavynode_minions attempt
local retries=20
local retry_count=0
local delay=180
local heavynode_discovery_success=false
HEAVYNODE_ES_VERSIONS=""
for attempt in {1..3}; do
if heavynode_minions=$(set -o pipefail; salt-key --out=json --list=accepted 2> /dev/null | jq -r '.minions[]? | select(endswith("heavynode"))'); then
heavynode_discovery_success=true
break
fi
echo "Failed to retrieve grid heavynodes via salt-key... Retrying in 30 seconds. Attempt $attempt of 3."
sleep 30
done
if [[ "$heavynode_discovery_success" != "true" ]]; then
echo "Failed to retrieve grid heavynodes via salt-key."
return 1
fi
if [[ -z "$heavynode_minions" ]]; then
echo "No heavynodes detected. Skipping heavynode Elasticsearch version compatibility check."
return 0
fi
while [[ $retry_count -lt $retries ]]; do
HEAVYNODE_ES_VERSIONS=$(salt -C 'G@role:so-heavynode' cmd.run 'set -o pipefail; so-elasticsearch-query / --retry 5 --retry-delay 10 | jq -er ".version.number"' shell=/bin/bash --out=json 2> /dev/null)
local exit_status=$?
if [[ $exit_status -ne 0 ]]; then
echo "Failed to retrieve Elasticsearch version from one or more heavynodes... Retrying in $delay seconds. Attempt $((retry_count + 1)) of $retries."
((retry_count++))
sleep $delay
continue
fi
local all_heavynodes_compatible=true
while IFS=$'\t' read -r node current_version; do
[[ -z "$node" ]] && continue
if ! es_version_can_upgrade_to_target "$current_version"; then
echo "Heavynode $node is running Elasticsearch $current_version, which is not directly upgradable to Elasticsearch $target_es_version."
all_heavynodes_compatible=false
fi
done < <(echo "$HEAVYNODE_ES_VERSIONS" | jq -r 'to_entries[] | [.key, .value] | @tsv')
while IFS= read -r heavynode_minion; do
[[ -z "$heavynode_minion" ]] && continue
if ! echo "$HEAVYNODE_ES_VERSIONS" | jq -se --arg minion "$heavynode_minion" 'add | has($minion)' > /dev/null; then
echo "Heavynode $heavynode_minion did not report an Elasticsearch version. It may be offline or still upgrading."
all_heavynodes_compatible=false
fi
done <<< "$heavynode_minions"
if [[ "$all_heavynodes_compatible" == true ]]; then
echo -e "\nAll heavynodes can upgrade to Elasticsearch $target_es_version."
return 0
fi
echo "One or more heavynodes cannot upgrade directly to Elasticsearch $target_es_version. Rechecking in $delay seconds. Attempt $((retry_count + 1)) of $retries."
((retry_count++))
sleep $delay
done
return 1
}
if [[ ! -f "$es_verification_script" ]]; then
create_intermediate_upgrade_verification_script "$es_verification_script"
fi
for statefile in "${es_required_version_statefile_base}"-*; do for statefile in "${es_required_version_statefile_base}"-*; do
[[ -f $statefile ]] || continue [[ -f $statefile ]] || continue
@@ -850,10 +1239,6 @@ verify_es_version_compatibility() {
continue continue
fi fi
if [[ ! -f "$es_verification_script" ]]; then
create_intermediate_upgrade_verification_script "$es_verification_script"
fi
echo -e "\n##############################################################################################################################\n" echo -e "\n##############################################################################################################################\n"
echo "A previously required intermediate Elasticsearch upgrade was detected. Verifying that all Searchnodes/Heavynodes have successfully upgraded Elasticsearch to $es_required_version_statefile_value before proceeding with soup to avoid potential data loss! This command can take up to an hour to complete." echo "A previously required intermediate Elasticsearch upgrade was detected. Verifying that all Searchnodes/Heavynodes have successfully upgraded Elasticsearch to $es_required_version_statefile_value before proceeding with soup to avoid potential data loss! This command can take up to an hour to complete."
if ! timeout --foreground 4000 bash "$es_verification_script" "$es_required_version_statefile_value" "$statefile"; then if ! timeout --foreground 4000 bash "$es_verification_script" "$es_required_version_statefile_value" "$statefile"; then
@@ -875,6 +1260,26 @@ verify_es_version_compatibility() {
# shellcheck disable=SC2076 # Do not want a regex here eg usage " 8.18.8 9.0.8 " =~ " 9.0.8 " # shellcheck disable=SC2076 # Do not want a regex here eg usage " 8.18.8 9.0.8 " =~ " 9.0.8 "
if [[ " ${es_upgrade_map[$es_version]} " =~ " $target_es_version " || "$es_version" == "$target_es_version" ]]; then if [[ " ${es_upgrade_map[$es_version]} " =~ " $target_es_version " || "$es_version" == "$target_es_version" ]]; then
if ! verify_searchnodes_es_target_compatibility || ! verify_heavynodes_es_target_compatibility; then
echo -e "\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n"
echo "One or more Searchnode(s)/Heavynode(s) cannot upgrade directly to Elasticsearch $target_es_version. This can happen with soups that include Elasticsearch upgrades being run in quick succession. Typically, this will resolve itself as the grid synchronizes. Please allow time for all Searchnodes/Heavynodes to have upgraded Elasticsearch to a compatible version with $target_es_version before running soup again to avoid potential data loss!"
if [[ -n "$HEAVYNODE_ES_VERSIONS" ]]; then
echo "Current heavynode Elasticsearch versions:"
echo "$HEAVYNODE_ES_VERSIONS" | jq '.'
fi
if [[ -n "$SEARCHNODE_ES_VERSIONS" ]]; then
echo "Current searchnode Elasticsearch versions:"
echo "$SEARCHNODE_ES_VERSIONS" | jq '.nodes | to_entries | map({(.value.name): .value.version}) | sort | add'
fi
echo -e "\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n"
exit 161
fi
# supported upgrade # supported upgrade
return 0 return 0
else else
@@ -1232,13 +1637,13 @@ main() {
echo "Verifying we have the latest soup script." echo "Verifying we have the latest soup script."
verify_latest_update_script verify_latest_update_script
echo "Verifying Elasticsearch version compatibility before upgrading."
verify_es_version_compatibility
echo "Let's see if we need to update Security Onion." echo "Let's see if we need to update Security Onion."
upgrade_check upgrade_check
upgrade_space upgrade_space
echo "Verifying Elasticsearch version compatibility across the grid before upgrading."
verify_es_version_compatibility
echo "Checking for Salt Master and Minion updates." echo "Checking for Salt Master and Minion updates."
upgrade_check_salt upgrade_check_salt
set -e set -e
@@ -1258,7 +1663,8 @@ main() {
echo "Applying $HOTFIXVERSION hotfix" echo "Applying $HOTFIXVERSION hotfix"
# since we don't run the backup.config_backup state on import we wont snapshot previous version states and pillars # since we don't run the backup.config_backup state on import we wont snapshot previous version states and pillars
if [[ ! "$MINION_ROLE" == "import" ]]; then if [[ ! "$MINION_ROLE" == "import" ]]; then
backup_old_states_pillars echo "Running so-config-backup script."
/sbin/so-config-backup
fi fi
copy_new_files copy_new_files
create_local_directories "/opt/so/saltstack/default" create_local_directories "/opt/so/saltstack/default"
@@ -1314,8 +1720,8 @@ main() {
# since we don't run the backup.config_backup state on import we wont snapshot previous version states and pillars # since we don't run the backup.config_backup state on import we wont snapshot previous version states and pillars
if [[ ! "$MINION_ROLE" == "import" ]]; then if [[ ! "$MINION_ROLE" == "import" ]]; then
echo "" echo ""
echo "Creating snapshots of default and local Salt states and pillars and saving to /nsm/backup/" echo "Running so-config-backup script."
backup_old_states_pillars /sbin/so-config-backup
fi fi
echo "" echo ""
+2
View File
@@ -225,6 +225,7 @@ http {
limit_req zone=auth_throttle burst={{ NGINXMERGED.config.throttle_login_burst }} nodelay; limit_req zone=auth_throttle burst={{ NGINXMERGED.config.throttle_login_burst }} nodelay;
limit_req_status 429; limit_req_status 429;
proxy_pass http://{{ GLOBALS.manager }}:4433; proxy_pass http://{{ GLOBALS.manager }}:4433;
proxy_set_header Connection "Close";
proxy_read_timeout 90; proxy_read_timeout 90;
proxy_connect_timeout 90; proxy_connect_timeout 90;
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -237,6 +238,7 @@ http {
location ~ ^/auth/.*?(whoami|logout|settings|errors|webauthn.js) { location ~ ^/auth/.*?(whoami|logout|settings|errors|webauthn.js) {
rewrite /auth/(.*) /$1 break; rewrite /auth/(.*) /$1 break;
proxy_pass http://{{ GLOBALS.manager }}:4433; proxy_pass http://{{ GLOBALS.manager }}:4433;
proxy_set_header Connection "Close";
proxy_read_timeout 90; proxy_read_timeout 90;
proxy_connect_timeout 90; proxy_connect_timeout 90;
proxy_set_header Host $host; proxy_set_header Host $host;
+10 -1
View File
@@ -3,7 +3,14 @@
# https://securityonion.net/license; you may not use this file except in compliance with the # https://securityonion.net/license; you may not use this file except in compliance with the
# Elastic License 2.0. # Elastic License 2.0.
{% set hypervisor = pillar.minion_id %} {% set hypervisor = pillar.get('minion_id', '') %}
{% if not hypervisor|regex_match('^([A-Za-z0-9._-]{1,253})$') %}
{% do salt.log.error('delete_hypervisor_orch: refusing unsafe minion_id=' ~ hypervisor) %}
delete_hypervisor_invalid_minion_id:
test.fail_without_changes:
- name: delete_hypervisor_invalid_minion_id
{% else %}
ensure_hypervisor_mine_deleted: ensure_hypervisor_mine_deleted:
salt.function: salt.function:
@@ -20,3 +27,5 @@ update_salt_cloud_profile:
- sls: - sls:
- salt.cloud.config - salt.cloud.config
- concurrent: True - concurrent: True
{% endif %}
+10 -1
View File
@@ -12,7 +12,14 @@
{% if 'vrt' in salt['pillar.get']('features', []) %} {% if 'vrt' in salt['pillar.get']('features', []) %}
{% do salt.log.debug('vm_pillar_clean_orch: Running') %} {% do salt.log.debug('vm_pillar_clean_orch: Running') %}
{% set vm_name = pillar.get('vm_name') %} {% set vm_name = pillar.get('vm_name', '') %}
{% if not vm_name|regex_match('^([A-Za-z0-9._-]{1,253})$') %}
{% do salt.log.error('vm_pillar_clean_orch: refusing unsafe vm_name=' ~ vm_name) %}
vm_pillar_clean_invalid_name:
test.fail_without_changes:
- name: vm_pillar_clean_invalid_name
{% else %}
delete_adv_{{ vm_name }}_pillar: delete_adv_{{ vm_name }}_pillar:
module.run: module.run:
@@ -24,6 +31,8 @@ delete_{{ vm_name }}_pillar:
- file.remove: - file.remove:
- path: /opt/so/saltstack/local/pillar/minions/{{ vm_name }}.sls - path: /opt/so/saltstack/local/pillar/minions/{{ vm_name }}.sls
{% endif %}
{% else %} {% else %}
{% do salt.log.error( {% do salt.log.error(
+3 -3
View File
@@ -46,10 +46,10 @@ postgresinitdir:
- require: - require:
- file: postgresconfdir - file: postgresconfdir
postgresinitusers: postgresinitdb:
file.managed: file.managed:
- name: /opt/so/conf/postgres/init/init-users.sh - name: /opt/so/conf/postgres/init/init-db.sh
- source: salt://postgres/files/init-users.sh - source: salt://postgres/files/init-db.sh
- user: 939 - user: 939
- group: 939 - group: 939
- mode: 755 - mode: 755
+4 -4
View File
@@ -31,7 +31,7 @@ so-postgres:
- POSTGRES_DB=securityonion - POSTGRES_DB=securityonion
# Passwords are delivered via mounted 0600 secret files, not plaintext env vars. # Passwords are delivered via mounted 0600 secret files, not plaintext env vars.
# The upstream postgres image resolves POSTGRES_PASSWORD_FILE; entrypoint.sh and # The upstream postgres image resolves POSTGRES_PASSWORD_FILE; entrypoint.sh and
# init-users.sh resolve SO_POSTGRES_PASS_FILE the same way. # init-db.sh resolve SO_POSTGRES_PASS_FILE the same way.
- POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password - POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password
- SO_POSTGRES_USER={{ SO_POSTGRES_USER }} - SO_POSTGRES_USER={{ SO_POSTGRES_USER }}
- SO_POSTGRES_PASS_FILE=/run/secrets/so_postgres_pass - SO_POSTGRES_PASS_FILE=/run/secrets/so_postgres_pass
@@ -46,7 +46,7 @@ so-postgres:
- /opt/so/conf/postgres/postgresql.conf:/conf/postgresql.conf:ro - /opt/so/conf/postgres/postgresql.conf:/conf/postgresql.conf:ro
- /opt/so/conf/postgres/pg_hba.conf:/conf/pg_hba.conf:ro - /opt/so/conf/postgres/pg_hba.conf:/conf/pg_hba.conf:ro
- /opt/so/conf/postgres/secrets:/run/secrets:ro - /opt/so/conf/postgres/secrets:/run/secrets:ro
- /opt/so/conf/postgres/init/init-users.sh:/docker-entrypoint-initdb.d/init-users.sh:ro - /opt/so/conf/postgres/init/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh:ro
- /etc/pki/postgres.crt:/conf/postgres.crt:ro - /etc/pki/postgres.crt:/conf/postgres.crt:ro
- /etc/pki/postgres.key:/conf/postgres.key:ro - /etc/pki/postgres.key:/conf/postgres.key:ro
- /etc/pki/tls/certs/intca.crt:/conf/ca.crt:ro - /etc/pki/tls/certs/intca.crt:/conf/ca.crt:ro
@@ -70,7 +70,7 @@ so-postgres:
- watch: - watch:
- file: postgresconf - file: postgresconf
- file: postgreshba - file: postgreshba
- file: postgresinitusers - file: postgresinitdb
- file: postgres_super_secret - file: postgres_super_secret
- file: postgres_app_secret - file: postgres_app_secret
- x509: postgres_crt - x509: postgres_crt
@@ -78,7 +78,7 @@ so-postgres:
- require: - require:
- file: postgresconf - file: postgresconf
- file: postgreshba - file: postgreshba
- file: postgresinitusers - file: postgresinitdb
- file: postgres_super_secret - file: postgres_super_secret
- file: postgres_app_secret - file: postgres_app_secret
- x509: postgres_crt - x509: postgres_crt
@@ -17,6 +17,7 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-E
END IF; END IF;
END END
\$\$; \$\$;
GRANT ALL ON SCHEMA public TO "$SO_POSTGRES_USER";
GRANT ALL PRIVILEGES ON DATABASE "$POSTGRES_DB" TO "$SO_POSTGRES_USER"; GRANT ALL PRIVILEGES ON DATABASE "$POSTGRES_DB" TO "$SO_POSTGRES_USER";
-- Lock the SOC database down at the connect layer; PUBLIC gets CONNECT -- Lock the SOC database down at the connect layer; PUBLIC gets CONNECT
-- by default, which would let per-minion telegraf roles open sessions -- by default, which would let per-minion telegraf roles open sessions
+18 -85
View File
@@ -18,38 +18,22 @@ include:
{% set TG_OUT = TELEGRAFMERGED.output | upper %} {% set TG_OUT = TELEGRAFMERGED.output | upper %}
{% if TG_OUT in ['POSTGRES', 'BOTH'] %} {% if TG_OUT in ['POSTGRES', 'BOTH'] %}
# docker_container.running returns as soon as the container starts, but on
# first-init docker-entrypoint.sh starts a temporary postgres with
# `listen_addresses=''` to run /docker-entrypoint-initdb.d scripts, then
# shuts it down before exec'ing the real CMD. A default pg_isready check
# (Unix socket) passes during that ephemeral phase and races the shutdown
# with "the database system is shutting down". Checking TCP readiness on
# 127.0.0.1 only succeeds after the final postgres binds the port.
postgres_wait_ready: postgres_wait_ready:
cmd.run: cmd.run:
- name: | - name: /usr/sbin/so-postgres-wait
for i in $(seq 1 60); do
if docker exec so-postgres pg_isready -h 127.0.0.1 -U postgres -q 2>/dev/null; then
exit 0
fi
sleep 2
done
echo "so-postgres did not accept TCP connections within 120s" >&2
exit 1
- require: - require:
- docker_container: so-postgres - docker_container: so-postgres
- file: postgres_sbin
# Ensure the shared Telegraf database exists. init-users.sh only runs on a # Ensure the shared Telegraf database exists. init-db.sh only runs on a
# fresh data dir, so hosts upgraded onto an existing /nsm/postgres volume # fresh data dir, so hosts upgraded onto an existing /nsm/postgres volume
# would otherwise never get so_telegraf. # would otherwise never get so_telegraf.
postgres_create_telegraf_db: postgres_create_telegraf_db:
cmd.run: cmd.run:
- name: | - name: /usr/sbin/so-telegraf-postgres create_db
if ! docker exec so-postgres psql -U postgres -tAc "SELECT 1 FROM pg_database WHERE datname='so_telegraf'" | grep -q 1; then
docker exec so-postgres psql -v ON_ERROR_STOP=1 -U postgres -c "CREATE DATABASE so_telegraf"
fi
- require: - require:
- cmd: postgres_wait_ready - cmd: postgres_wait_ready
- file: postgres_sbin
# Provision the shared group role and schema once. Every per-minion role is a # Provision the shared group role and schema once. Every per-minion role is a
# member of so_telegraf, and each Telegraf connection does SET ROLE so_telegraf # member of so_telegraf, and each Telegraf connection does SET ROLE so_telegraf
@@ -57,68 +41,26 @@ postgres_create_telegraf_db:
# on first write are owned by the group role and every member can INSERT/SELECT. # on first write are owned by the group role and every member can INSERT/SELECT.
postgres_telegraf_group_role: postgres_telegraf_group_role:
cmd.run: cmd.run:
- name: | - name: /usr/sbin/so-telegraf-postgres group_role
docker exec -i so-postgres psql -v ON_ERROR_STOP=1 -U postgres -d so_telegraf <<'EOSQL'
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'so_telegraf') THEN
CREATE ROLE so_telegraf NOLOGIN;
END IF;
END
$$;
GRANT CONNECT ON DATABASE so_telegraf TO so_telegraf;
CREATE SCHEMA IF NOT EXISTS telegraf AUTHORIZATION so_telegraf;
GRANT USAGE, CREATE ON SCHEMA telegraf TO so_telegraf;
CREATE SCHEMA IF NOT EXISTS partman;
CREATE EXTENSION IF NOT EXISTS pg_partman SCHEMA partman;
CREATE EXTENSION IF NOT EXISTS pg_cron;
-- Telegraf (running as so_telegraf) calls partman.create_parent()
-- on first write of each metric, which needs USAGE on the partman
-- schema, EXECUTE on its functions/procedures, and write access to
-- partman.part_config so it can register new partitioned parents.
GRANT USAGE, CREATE ON SCHEMA partman TO so_telegraf;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA partman TO so_telegraf;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA partman TO so_telegraf;
GRANT EXECUTE ON ALL PROCEDURES IN SCHEMA partman TO so_telegraf;
-- partman creates per-parent template tables (partman.template_*) at
-- runtime; default privileges extend DML/sequence access to them.
ALTER DEFAULT PRIVILEGES IN SCHEMA partman
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO so_telegraf;
ALTER DEFAULT PRIVILEGES IN SCHEMA partman
GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO so_telegraf;
-- Hourly partman maintenance. cron.schedule is idempotent by jobname.
SELECT cron.schedule(
'telegraf-partman-maintenance',
'17 * * * *',
'CALL partman.run_maintenance_proc()'
);
EOSQL
- require: - require:
- cmd: postgres_create_telegraf_db - cmd: postgres_create_telegraf_db
- file: postgres_sbin
{% set creds = salt['pillar.get']('telegraf:postgres_creds', {}) %} {% set creds = salt['pillar.get']('telegraf:postgres_creds', {}) %}
{% for mid, entry in creds.items() %} {% for mid, entry in creds.items() %}
{% if entry.get('user') and entry.get('pass') %} {% if entry.get('user') and entry.get('pass') %}
{% set u = entry.user %} {% set u = entry.user %}
{% set p = entry.pass | replace("'", "''") %} {% set p = entry.pass %}
postgres_telegraf_role_{{ u }}: postgres_telegraf_role_{{ u }}:
cmd.run: cmd.run:
- name: | - name: /usr/sbin/so-telegraf-postgres user
docker exec -i so-postgres psql -v ON_ERROR_STOP=1 -U postgres -d so_telegraf <<'EOSQL' - env:
DO $$ - ROLE_USER: {{ u | tojson }}
BEGIN - ROLE_PASS: {{ p | tojson }}
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '{{ u }}') THEN - hide_output: True
EXECUTE format('CREATE ROLE %I WITH LOGIN PASSWORD %L', '{{ u }}', '{{ p }}');
ELSE
EXECUTE format('ALTER ROLE %I WITH PASSWORD %L', '{{ u }}', '{{ p }}');
END IF;
END
$$;
GRANT CONNECT ON DATABASE so_telegraf TO "{{ u }}";
GRANT so_telegraf TO "{{ u }}";
EOSQL
- require: - require:
- file: postgres_sbin
- cmd: postgres_telegraf_group_role - cmd: postgres_telegraf_group_role
{% endif %} {% endif %}
@@ -130,21 +72,12 @@ postgres_telegraf_role_{{ u }}:
{% set retention = salt['pillar.get']('postgres:telegraf:retention_days', 14) | int %} {% set retention = salt['pillar.get']('postgres:telegraf:retention_days', 14) | int %}
postgres_telegraf_retention_reconcile: postgres_telegraf_retention_reconcile:
cmd.run: cmd.run:
- name: | - name: /usr/sbin/so-telegraf-postgres retention
docker exec -i so-postgres psql -v ON_ERROR_STOP=1 -U postgres -d so_telegraf <<'EOSQL' - env:
DO $$ - RETENTION_DAYS: {{ retention }}
BEGIN
IF EXISTS (SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'pg_partman') THEN
UPDATE partman.part_config
SET retention = '{{ retention }} days',
retention_keep_table = false
WHERE parent_table LIKE 'telegraf.%';
END IF;
END
$$;
EOSQL
- require: - require:
- cmd: postgres_telegraf_group_role - cmd: postgres_telegraf_group_role
- file: postgres_sbin
{% endif %} {% endif %}
+41 -7
View File
@@ -7,15 +7,29 @@
. /usr/sbin/so-common . /usr/sbin/so-common
# Without pipefail, a pipeline's exit status is gzip's. A failed pg_dumpall would
# otherwise be masked by a successful gzip, silently producing a valid .gz that
# holds a truncated dump.
set -o pipefail
# Backups contain role password hashes and full chat data; keep them 0600. # Backups contain role password hashes and full chat data; keep them 0600.
umask 0077 umask 0077
TODAY=$(date '+%Y_%m_%d') TODAY=$(date '+%Y_%m_%d')
BACKUPDIR=/nsm/backup BACKUPDIR=/nsm/backup
BACKUPFILE="$BACKUPDIR/so-postgres-backup-$TODAY.sql.gz" BACKUPFILE="$BACKUPDIR/so-postgres-backup-$TODAY.sql.gz"
TMPFILE="$BACKUPFILE.tmp"
MAXBACKUPS=7 MAXBACKUPS=7
LOGFILE=/opt/so/log/postgres/backup.log
mkdir -p $BACKUPDIR log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOGFILE"
}
mkdir -p "$BACKUPDIR"
# Remove any temp files left behind by a previously crashed run
rm -f "$BACKUPDIR"/so-postgres-backup-*.sql.gz.tmp
# Skip if already backed up today # Skip if already backed up today
if [ -f "$BACKUPFILE" ]; then if [ -f "$BACKUPFILE" ]; then
@@ -27,13 +41,33 @@ if ! docker ps --format '{{.Names}}' | grep -q '^so-postgres$'; then
exit 0 exit 0
fi fi
# Dump all databases and roles, compress # Always clean up the temp file on exit; the success path clears this trap
docker exec so-postgres pg_dumpall -U postgres | gzip > "$BACKUPFILE" # after the atomic rename so the finished backup is not deleted.
trap 'rm -f "$TMPFILE"' EXIT
# Retention cleanup # Dump all databases and roles, compress. Write to a temp file so the final
NUMBACKUPS=$(find $BACKUPDIR -type f -name "so-postgres-backup*" | wc -l) # filename only ever appears for a complete, verified backup.
if ! docker exec so-postgres pg_dumpall -U postgres | gzip > "$TMPFILE"; then
log "ERROR: pg_dumpall/gzip failed; backup aborted"
exit 1
fi
# Verify the compressed stream is intact before publishing it
if ! gzip -t "$TMPFILE"; then
log "ERROR: backup failed gzip integrity check; backup aborted"
exit 1
fi
# Atomically publish the verified backup
mv "$TMPFILE" "$BACKUPFILE"
trap - EXIT
log "OK: wrote $BACKUPFILE"
# Retention cleanup (only reached after a successful backup). The glob is
# restricted to finished backups so an in-progress .tmp can never be counted.
NUMBACKUPS=$(find "$BACKUPDIR" -type f -name "so-postgres-backup-*.sql.gz" | wc -l)
while [ "$NUMBACKUPS" -gt "$MAXBACKUPS" ]; do while [ "$NUMBACKUPS" -gt "$MAXBACKUPS" ]; do
OLDEST=$(find $BACKUPDIR -type f -name "so-postgres-backup*" -printf '%T+ %p\n' | sort | head -n 1 | awk -F" " '{print $2}') OLDEST=$(find "$BACKUPDIR" -type f -name "so-postgres-backup-*.sql.gz" -printf '%T+ %p\n' | sort | head -n 1 | awk -F" " '{print $2}')
rm -f "$OLDEST" rm -f "$OLDEST"
NUMBACKUPS=$(find $BACKUPDIR -type f -name "so-postgres-backup*" | wc -l) NUMBACKUPS=$(find "$BACKUPDIR" -type f -name "so-postgres-backup-*.sql.gz" | wc -l)
done done
+32
View File
@@ -0,0 +1,32 @@
#!/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.
# Wait for the so-postgres container to accept TCP connections.
#
# docker_container.running returns as soon as the container starts, but on
# first-init docker-entrypoint.sh starts a temporary postgres with
# `listen_addresses=''` to run /docker-entrypoint-initdb.d scripts, then
# shuts it down before exec'ing the real CMD. A default pg_isready check
# (Unix socket) passes during that ephemeral phase and races the shutdown
# with "the database system is shutting down". Checking TCP readiness on
# 127.0.0.1 only succeeds after the final postgres binds the port.
#
# Usage: so-postgres-wait [iterations] [sleep_seconds]
# Default: 60 iterations, 2s sleep (~120s total).
ITERATIONS=${1:-60}
SLEEP_SECONDS=${2:-2}
for i in $(seq 1 "$ITERATIONS"); do
if docker exec so-postgres pg_isready -h 127.0.0.1 -U postgres -q 2>/dev/null; then
exit 0
fi
sleep "$SLEEP_SECONDS"
done
echo "so-postgres did not accept TCP connections within $((ITERATIONS * SLEEP_SECONDS))s" >&2
exit 1
@@ -0,0 +1,110 @@
#!/bin/bash
set -e
# Provision Telegraf state inside the so-postgres container.
# Usage: so-telegraf-postgres <subcommand>
# create_db Ensure the so_telegraf database exists.
# group_role Provision the so_telegraf group role, telegraf/partman schemas,
# pg_partman, pg_cron, and the hourly partman maintenance job.
# user Create or update a per-minion login role granted to so_telegraf.
# Env: ROLE_USER, ROLE_PASS.
# retention Reconcile partman retention on telegraf parents.
# Env: RETENTION_DAYS.
cmd="${1:?subcommand required}"
case "$cmd" in
create_db)
if ! docker exec so-postgres psql -U postgres -tAc \
"SELECT 1 FROM pg_database WHERE datname='so_telegraf'" | grep -q 1; then
docker exec so-postgres psql -v ON_ERROR_STOP=1 -U postgres \
-c "CREATE DATABASE so_telegraf"
fi
;;
group_role)
docker exec -i so-postgres psql -v ON_ERROR_STOP=1 -U postgres -d so_telegraf <<'EOSQL'
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'so_telegraf') THEN
CREATE ROLE so_telegraf NOLOGIN;
END IF;
END
$$;
GRANT CONNECT ON DATABASE so_telegraf TO so_telegraf;
CREATE SCHEMA IF NOT EXISTS telegraf AUTHORIZATION so_telegraf;
GRANT USAGE, CREATE ON SCHEMA telegraf TO so_telegraf;
CREATE SCHEMA IF NOT EXISTS partman;
CREATE EXTENSION IF NOT EXISTS pg_partman SCHEMA partman;
CREATE EXTENSION IF NOT EXISTS pg_cron;
-- Telegraf (running as so_telegraf) calls partman.create_parent()
-- on first write of each metric, which needs USAGE on the partman
-- schema, EXECUTE on its functions/procedures, and write access to
-- partman.part_config so it can register new partitioned parents.
GRANT USAGE, CREATE ON SCHEMA partman TO so_telegraf;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA partman TO so_telegraf;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA partman TO so_telegraf;
GRANT EXECUTE ON ALL PROCEDURES IN SCHEMA partman TO so_telegraf;
-- partman creates per-parent template tables (partman.template_*) at
-- runtime; default privileges extend DML/sequence access to them.
ALTER DEFAULT PRIVILEGES IN SCHEMA partman
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO so_telegraf;
ALTER DEFAULT PRIVILEGES IN SCHEMA partman
GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO so_telegraf;
-- Hourly partman maintenance. cron.schedule is idempotent by jobname.
SELECT cron.schedule(
'telegraf-partman-maintenance',
'17 * * * *',
'CALL partman.run_maintenance_proc()'
);
EOSQL
;;
user)
: "${ROLE_USER:?ROLE_USER is required}"
: "${ROLE_PASS:?ROLE_PASS is required}"
# psql does not substitute :vars inside dollar-quoted strings, so the
# conditional CREATE/ALTER is built outside any DO block and dispatched
# with \gexec. format() handles identifier/literal quoting.
docker exec -i so-postgres psql \
-v ON_ERROR_STOP=1 \
-v role_user="$ROLE_USER" \
-v role_pass="$ROLE_PASS" \
-U postgres -d so_telegraf <<'EOSQL'
SELECT format(
CASE WHEN EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = :'role_user')
THEN 'ALTER ROLE %I WITH LOGIN PASSWORD %L'
ELSE 'CREATE ROLE %I WITH LOGIN PASSWORD %L'
END,
:'role_user',
:'role_pass'
) \gexec
GRANT CONNECT ON DATABASE so_telegraf TO :"role_user";
GRANT so_telegraf TO :"role_user";
EOSQL
;;
retention)
: "${RETENTION_DAYS:?RETENTION_DAYS is required}"
# \gset + \if guards against a missing pg_partman without using a DO
# block (psql :var substitution doesn't reach into dollar-quoted code).
docker exec -i so-postgres psql \
-v ON_ERROR_STOP=1 \
-v retention_days="$RETENTION_DAYS" \
-U postgres -d so_telegraf <<'EOSQL'
SELECT CASE WHEN EXISTS (SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'pg_partman')
THEN 'true' ELSE 'false' END AS has_partman \gset
\if :has_partman
UPDATE partman.part_config
SET retention = :'retention_days' || ' days',
retention_keep_table = false
WHERE parent_table LIKE 'telegraf.%';
\endif
EOSQL
;;
*)
echo "Unknown subcommand: $cmd" >&2
exit 1
;;
esac
+6 -4
View File
@@ -3,12 +3,15 @@
# https://securityonion.net/license; you may not use this file except in compliance with the # https://securityonion.net/license; you may not use this file except in compliance with the
# Elastic License 2.0. # Elastic License 2.0.
{% if data['id'].endswith('_hypervisor') and data['result'] == True %} {% set hid = data['id'] %}
{% if hid|regex_match('^([A-Za-z0-9._-]{1,253})$')
and hid.endswith('_hypervisor')
and data['result'] == True %}
{% if data['act'] == 'accept' %} {% if data['act'] == 'accept' %}
check_and_trigger: check_and_trigger:
runner.setup_hypervisor.setup_environment: runner.setup_hypervisor.setup_environment:
- minion_id: {{ data['id'] }} - minion_id: {{ hid }}
{% endif %} {% endif %}
{% if data['act'] == 'delete' %} {% if data['act'] == 'delete' %}
@@ -17,8 +20,7 @@ delete_hypervisor:
- args: - args:
- mods: orch.delete_hypervisor - mods: orch.delete_hypervisor
- pillar: - pillar:
minion_id: {{ data['id'] }} minion_id: {{ hid }}
{% endif %} {% endif %}
{% endif %} {% endif %}
+26 -14
View File
@@ -9,30 +9,42 @@ import logging
import os import os
import pwd import pwd
import grp import grp
import re
log = logging.getLogger(__name__)
PILLAR_ROOT = '/opt/so/saltstack/local/pillar/minions/'
_VMNAME_RE = re.compile(r'^[A-Za-z0-9._-]{1,253}$')
def run(): def run():
vm_name = data['kwargs']['name'] vm_name = data.get('kwargs', {}).get('name', '')
logging.error("createEmptyPillar reactor: vm_name: %s" % vm_name) if not _VMNAME_RE.match(str(vm_name)):
pillar_root = '/opt/so/saltstack/local/pillar/minions/' log.error("createEmptyPillar reactor: refusing unsafe vm_name=%r", vm_name)
return {}
log.info("createEmptyPillar reactor: vm_name: %s", vm_name)
pillar_files = ['adv_' + vm_name + '.sls', vm_name + '.sls'] pillar_files = ['adv_' + vm_name + '.sls', vm_name + '.sls']
try: try:
# Get socore user and group IDs
socore_uid = pwd.getpwnam('socore').pw_uid socore_uid = pwd.getpwnam('socore').pw_uid
socore_gid = grp.getgrnam('socore').gr_gid socore_gid = grp.getgrnam('socore').gr_gid
pillar_root_real = os.path.realpath(PILLAR_ROOT)
for f in pillar_files: for f in pillar_files:
full_path = pillar_root + f full_path = os.path.join(PILLAR_ROOT, f)
if not os.path.exists(full_path): resolved = os.path.realpath(full_path)
# Create empty file if os.path.dirname(resolved) != pillar_root_real:
os.mknod(full_path) log.error("createEmptyPillar reactor: refusing path outside pillar root: %s", resolved)
# Set ownership to socore:socore continue
os.chown(full_path, socore_uid, socore_gid) if os.path.exists(resolved):
# Set mode to 644 (rw-r--r--) continue
os.chmod(full_path, 0o640) os.mknod(resolved)
logging.error("createEmptyPillar reactor: created %s with socore:socore ownership and mode 644" % f) os.chown(resolved, socore_uid, socore_gid)
os.chmod(resolved, 0o640)
log.info("createEmptyPillar reactor: created %s with socore:socore ownership and mode 0640", f)
except (KeyError, OSError) as e: except (KeyError, OSError) as e:
logging.error("createEmptyPillar reactor: Error setting ownership/permissions: %s" % str(e)) log.error("createEmptyPillar reactor: Error setting ownership/permissions: %s", e)
return {} return {}
+33 -11
View File
@@ -1,18 +1,40 @@
#!py
# Copyright Security Onion Solutions LLC and/or licensed to Security Onion Solutions LLC under one # 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 # 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 # https://securityonion.net/license; you may not use this file except in compliance with the
# Elastic License 2.0. # Elastic License 2.0.
remove_key: import logging
wheel.key.delete: import re
- args:
- match: {{ data['name'] }}
{{ data['name'] }}_pillar_clean: log = logging.getLogger(__name__)
runner.state.orchestrate:
- args:
- mods: orch.vm_pillar_clean
- pillar:
vm_name: {{ data['name'] }}
{% do salt.log.info('deleteKey reactor: deleted minion key: %s' % data['name']) %} _VMNAME_RE = re.compile(r'^[A-Za-z0-9._-]{1,253}$')
def run():
name = data.get('name', '')
if not _VMNAME_RE.match(str(name)):
log.error("deleteKey reactor: refusing unsafe name=%r", name)
return {}
log.info("deleteKey reactor: deleted minion key: %s", name)
return {
'remove_key': {
'wheel.key.delete': [
{'args': [
{'match': name},
]},
],
},
'%s_pillar_clean' % name: {
'runner.state.orchestrate': [
{'args': [
{'mods': 'orch.vm_pillar_clean'},
{'pillar': {'vm_name': name}},
]},
],
},
}
+1
View File
@@ -14,6 +14,7 @@
include: include:
- salt.minion - salt.minion
- salt.master.boot_mine_update
{% if 'vrt' in salt['pillar.get']('features', []) %} {% if 'vrt' in salt['pillar.get']('features', []) %}
- salt.cloud - salt.cloud
- salt.cloud.reactor_config_hypervisor - salt.cloud.reactor_config_hypervisor
+29
View File
@@ -0,0 +1,29 @@
# 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.
# Manages /etc/systemd/system/so-boot-mine-update.service, a manager-only
# Type=oneshot unit that pushes `salt '*' mine.update` once per boot, ordered
# before so-boot-highstate.service so mine-backed pillars (node IPs, ES/Redis/
# Logstash discovery) are fresh before the boot highstate renders them.
include:
- systemd.reload
so_boot_mine_update_unit_file:
file.managed:
- name: /etc/systemd/system/so-boot-mine-update.service
- source: salt://salt/service/so-boot-mine-update.service
- onchanges_in:
- module: systemd_reload
# Only enable once setup is complete. Until then the gate file is missing and
# the unit's own ConditionPathExists would no-op it anyway.
so_boot_mine_update_service:
service.enabled:
- name: so-boot-mine-update.service
- onlyif: test -e /opt/so/state/setup-complete
- require:
- file: so_boot_mine_update_unit_file
- module: systemd_reload
+31
View File
@@ -0,0 +1,31 @@
# 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.
# Manages /etc/systemd/system/so-boot-highstate.service, a Type=oneshot
# RemainAfterExit=yes unit that runs `salt-call state.highstate` exactly once
# per system boot. Replaces the legacy `startup_states: highstate` minion
# config, which fired on every salt-minion service restart (causing a redundant
# highstate whenever a highstate itself restarted salt-minion).
include:
- systemd.reload
so_boot_highstate_unit_file:
file.managed:
- name: /etc/systemd/system/so-boot-highstate.service
- source: salt://salt/service/so-boot-highstate.service
- onchanges_in:
- module: systemd_reload
# Only enable once setup is complete. Until then the gate file is missing and
# the unit's own ConditionPathExists would no-op it anyway -- this just keeps
# `systemctl is-enabled` honest for the sync_es_users gate.
so_boot_highstate_service:
service.enabled:
- name: so-boot-highstate.service
- onlyif: test -e /opt/so/state/setup-complete
- require:
- file: so_boot_highstate_unit_file
- module: systemd_reload
+27 -4
View File
@@ -17,6 +17,7 @@ include:
- repo.client - repo.client
- salt.mine_functions - salt.mine_functions
- salt.minion.service_file - salt.minion.service_file
- salt.minion.boot_highstate
{% if GLOBALS.is_manager %} {% if GLOBALS.is_manager %}
- ca.signing_policy - ca.signing_policy
{% endif %} {% endif %}
@@ -80,11 +81,33 @@ set_log_levels:
- "log_level: info" - "log_level: info"
- "log_level_logfile: info" - "log_level_logfile: info"
enable_startup_states: # startup_states: highstate caused a full highstate to run on every
file.uncomment: # salt-minion service start, including the restart triggered when a highstate
# itself modified the minion config (beacons, mine, unit file). Replaced by
# so-boot-highstate.service (managed in salt.minion.boot_highstate), which
# runs once per system boot only. Strip the line from /etc/salt/minion on
# upgrade; both the commented and uncommented forms historically existed.
remove_startup_states:
file.line:
- name: /etc/salt/minion - name: /etc/salt/minion
- regex: '^startup_states: highstate$' - match: 'startup_states: highstate'
- unless: pgrep so-setup - mode: delete
# Upgrade-path bridge: systems that already passed setup under the old gate
# (`grep -x 'startup_states: highstate' /etc/salt/minion`) get a /opt/so/state/setup-complete
# marker so so-boot-highstate.service can be enabled and the so-user_sync cron
# in sync_es_users.sls keeps installing. Setup-in-progress systems instead get
# the marker from `mark_setup_complete` in setup/so-functions at the right
# moment. `replace: false` means we never overwrite a marker once written.
mark_setup_complete_for_upgrades:
file.managed:
- name: /opt/so/state/setup-complete
- replace: false
- makedirs: True
- onlyif: "grep -qx 'startup_states: highstate' /etc/salt/minion"
- require_in:
- file: remove_startup_states
- service: so_boot_highstate_service
{% endif %} {% endif %}
@@ -0,0 +1,14 @@
[Unit]
Description=Security Onion boot-time highstate (runs once per boot)
After=salt-minion.service network-online.target docker.service
Wants=network-online.target docker.service
Requires=salt-minion.service
ConditionPathExists=/opt/so/state/setup-complete
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/salt-call state.highstate -l info queue=True
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,15 @@
[Unit]
Description=Security Onion boot-time grid mine.update (managers, runs once per boot before highstate)
After=salt-master.service salt-minion.service network-online.target
Wants=network-online.target
Requires=salt-master.service salt-minion.service
Before=so-boot-highstate.service
ConditionPathExists=/opt/so/state/setup-complete
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/sbin/so-boot-mine-update
[Install]
WantedBy=multi-user.target
-5
View File
@@ -8,11 +8,6 @@ set_role_grain:
- name: role - name: role
- value: so-{{ grains.id.split("_") | last }} - value: so-{{ grains.id.split("_") | last }}
set_highstate:
file.append:
- name: /etc/salt/minion
- text: 'startup_states: highstate'
enable_salt_minion: enable_salt_minion:
service.enabled: service.enabled:
- name: salt-minion - name: salt-minion
-5
View File
@@ -24,11 +24,6 @@
{% do SOCDEFAULTS.soc.config.server.modules.elastic.update({'username': GLOBALS.elasticsearch.auth.users.so_elastic_user.user, 'password': GLOBALS.elasticsearch.auth.users.so_elastic_user.pass}) %} {% do SOCDEFAULTS.soc.config.server.modules.elastic.update({'username': GLOBALS.elasticsearch.auth.users.so_elastic_user.user, 'password': GLOBALS.elasticsearch.auth.users.so_elastic_user.pass}) %}
{% if GLOBALS.postgres is defined and GLOBALS.postgres.auth is defined %}
{% set PG_ADMIN_PASS = salt['pillar.get']('secrets:postgres_pass', '') %}
{% do SOCDEFAULTS.soc.config.server.modules.update({'postgres': {'hostUrl': GLOBALS.manager_ip, 'port': 5432, 'username': GLOBALS.postgres.auth.users.so_postgres_user.user, 'password': GLOBALS.postgres.auth.users.so_postgres_user.pass, 'adminUser': 'postgres', 'adminPassword': PG_ADMIN_PASS, 'dbname': 'securityonion', 'sslMode': 'require', 'assistantEnabled': true, 'esHostUrl': 'https://' ~ GLOBALS.manager_ip ~ ':9200', 'esUsername': GLOBALS.elasticsearch.auth.users.so_elastic_user.user, 'esPassword': GLOBALS.elasticsearch.auth.users.so_elastic_user.pass, 'esVerifyCert': false}}) %}
{% endif %}
{% do SOCDEFAULTS.soc.config.server.modules.influxdb.update({'hostUrl': 'https://' ~ GLOBALS.influxdb_host ~ ':8086'}) %} {% do SOCDEFAULTS.soc.config.server.modules.influxdb.update({'hostUrl': 'https://' ~ GLOBALS.influxdb_host ~ ':8086'}) %}
{% do SOCDEFAULTS.soc.config.server.modules.influxdb.update({'token': INFLUXDB_TOKEN}) %} {% do SOCDEFAULTS.soc.config.server.modules.influxdb.update({'token': INFLUXDB_TOKEN}) %}
{% for tool in SOCDEFAULTS.soc.config.server.client.tools %} {% for tool in SOCDEFAULTS.soc.config.server.client.tools %}
+10
View File
@@ -1519,6 +1519,16 @@ soc:
serviceAccountJSON: "" serviceAccountJSON: ""
serviceAccountLocation: "" serviceAccountLocation: ""
healthTimeoutSeconds: 5 healthTimeoutSeconds: 5
onionconfig:
saltstackDir: /opt/so/saltstack
bypassEnabled: false
postgres:
host: ""
port: 5432
sslMode: "allow"
database: securityonion
user: ""
password: ""
salt: salt:
queueDir: /opt/sensoroni/queue queueDir: /opt/sensoroni/queue
timeoutMs: 45000 timeoutMs: 45000
+137 -1
View File
@@ -117,6 +117,121 @@ transformations:
- type: logsource - type: logsource
product: linux product: linux
service: auth service: auth
# Maps M365 audit rules to Elastic Agent O365 integration logs
- id: m365_audit_field_mappings
type: field_name_mapping
mapping:
Operation: event.action
ResultStatus: event.outcome
ApplicationId: o365.audit.ApplicationId
ObjectId: o365.audit.ObjectId
RequestType: o365.audit.RequestType
rule_conditions:
- type: logsource
product: m365
service: audit
- id: m365_audit_add-fields
type: add_condition
conditions:
event.dataset: 'o365.audit'
event.module: 'o365'
rule_conditions:
- type: logsource
product: m365
service: audit
# Maps M365 exchange rules to Elastic Agent O365 integration logs
- id: m365_exchange_field_mappings
type: field_name_mapping
mapping:
eventSource: event.provider
eventName: event.action
status: event.outcome
rule_conditions:
- type: logsource
product: m365
service: exchange
- id: m365_exchange_add-fields
type: add_condition
conditions:
event.dataset: 'o365.audit'
event.module: 'o365'
rule_conditions:
- type: logsource
product: m365
service: exchange
# Maps M365 threat_management rules to Elastic Agent O365 integration logs
- id: m365_threat_management_field_mappings
type: field_name_mapping
mapping:
eventSource: event.provider
eventName: event.action
status: event.outcome
rule_conditions:
- type: logsource
product: m365
service: threat_management
- id: m365_threat_management_add-fields
type: add_condition
conditions:
event.dataset: 'o365.audit'
event.module: 'o365'
rule_conditions:
- type: logsource
product: m365
service: threat_management
# Maps M365 threat_detection rules to Elastic Agent O365 integration logs
- id: m365_threat_detection_field_mappings
type: field_name_mapping
mapping:
eventSource: event.provider
eventName: event.action
status: event.outcome
rule_conditions:
- type: logsource
product: m365
service: threat_detection
- id: m365_threat_detection_add-fields
type: add_condition
conditions:
event.dataset: 'o365.audit'
event.module: 'o365'
rule_conditions:
- type: logsource
product: m365
service: threat_detection
# Maps FortiGate event rules to Elastic Agent Fortinet integration logs
- id: fortigate_event_field_mappings
type: field_name_mapping
mapping:
action: fortinet.firewall.action
cfgpath: fortinet.firewall.cfgpath
cfgobj: fortinet.firewall.cfgobj
cfgattr: fortinet.firewall.cfgattr
devname: observer.name
devid: observer.serial_number
logid: event.code
type: fortinet.firewall.type
subtype: fortinet.firewall.subtype
level: log.level
vd: fortinet.firewall.vd
logdesc: fortinet.firewall.desc
user: user.name
ui: fortinet.firewall.ui
cfgtid: fortinet.firewall.cfgtid
msg: message
rule_conditions:
- type: logsource
product: fortigate
service: event
- id: fortigate_event_add-fields
type: add_condition
conditions:
event.dataset: 'fortinet_fortigate.log'
event.module: 'fortinet_fortigate'
rule_conditions:
- type: logsource
product: fortigate
service: event
# event.code should always be a string # event.code should always be a string
- id: convert_event_code_to_string - id: convert_event_code_to_string
type: convert_type type: convert_type
@@ -126,15 +241,36 @@ transformations:
fields: fields:
- event.code - event.code
# Maps process_creation rules to endpoint process creation logs # Maps process_creation rules to endpoint process creation logs
# This is an OS-agnostic mapping, to account for logs that don't specify source OS
- id: endpoint_process_create_windows_add-fields - id: endpoint_process_create_windows_add-fields
type: add_condition type: add_condition
conditions: conditions:
event.category: 'process' event.category: 'process'
event.type: 'start' event.type: 'start'
host.os.type: 'windows'
rule_conditions: rule_conditions:
- type: logsource - type: logsource
category: process_creation category: process_creation
product: windows
- id: endpoint_process_create_macos_add-fields
type: add_condition
conditions:
event.category: 'process'
event.type: 'start'
host.os.type: 'macos'
rule_conditions:
- type: logsource
category: process_creation
product: macos
- id: endpoint_process_create_linux_add-fields
type: add_condition
conditions:
event.category: 'process'
event.type: 'start'
host.os.type: 'linux'
rule_conditions:
- type: logsource
category: process_creation
product: linux
# Maps file_event rules to endpoint file creation logs # Maps file_event rules to endpoint file creation logs
# This is an OS-agnostic mapping, to account for logs that don't specify source OS # This is an OS-agnostic mapping, to account for logs that don't specify source OS
- id: endpoint_file_create_add-fields - id: endpoint_file_create_add-fields
+8
View File
@@ -16,6 +16,14 @@
{% do SOCMERGED.config.server.update({'additionalCA': MANAGERMERGED.additionalCA}) %} {% do SOCMERGED.config.server.update({'additionalCA': MANAGERMERGED.additionalCA}) %}
{% do SOCMERGED.config.server.update({'insecureSkipVerify': MANAGERMERGED.insecureSkipVerify}) %} {% do SOCMERGED.config.server.update({'insecureSkipVerify': MANAGERMERGED.insecureSkipVerify}) %}
{% if not SOCMERGED.config.server.modules.postgres.host %}
{% do SOCMERGED.config.server.modules.postgres.update({'host': GLOBALS.manager}) %}
{% endif %}
{% if not SOCMERGED.config.server.modules.postgres.password %}
{% do SOCMERGED.config.server.modules.postgres.update({'password': salt['pillar.get']('postgres:auth:users:so_postgres_user:pass', '')}) %}
{% do SOCMERGED.config.server.modules.postgres.update({'user': salt['pillar.get']('postgres:auth:users:so_postgres_user:user', 'so_postgres')}) %}
{% endif %}
{# if SOCMERGED.config.server.modules.cases == httpcase details come from the soc pillar #} {# if SOCMERGED.config.server.modules.cases == httpcase details come from the soc pillar #}
{% if SOCMERGED.config.server.modules.cases != 'soc' %} {% if SOCMERGED.config.server.modules.cases != 'soc' %}
{% do SOCMERGED.config.server.modules.elastic.update({'casesEnabled': false}) %} {% do SOCMERGED.config.server.modules.elastic.update({'casesEnabled': false}) %}
+37
View File
@@ -453,6 +453,42 @@ soc:
description: Duration (in milliseconds) that must elapse after a grid node fails to check-in before the node will be marked offline (fault). description: Duration (in milliseconds) that must elapse after a grid node fails to check-in before the node will be marked offline (fault).
global: True global: True
advanced: True advanced: True
onionconfig:
saltstackDir:
description: Root directory containing the SaltStack tree that SOC reads and writes configuration from. Should not be changed under normal circumstances.
global: True
advanced: True
bypassEnabled:
description: When enabled, errors encountered while reading the SaltStack pillar tree (missing files, unreadable directories, etc.) are logged but do not prevent SOC from starting or serving settings. Intended for advanced troubleshooting and recovery scenarios when the pillar tree is partially unreadable.
global: True
advanced: True
forcedType: bool
postgres:
host:
description: Hostname or IP address of the PostgreSQL server used by SOC. Defaults to the manager hostname.
global: True
advanced: True
port:
description: Port of the PostgreSQL server used by SOC.
global: True
advanced: True
sslMode:
description: "Use encrypted connections to the PostgreSQL server. Must be one of the following values: disable, allow, prefer, require, verify-ca, verify-full. Defaults to allow."
global: True
advanced: True
database:
description: Database used by SOC to authenticate to the PostgreSQL server.
global: True
advanced: True
user:
description: Username used by SOC to authenticate to the PostgreSQL server.
global: True
advanced: True
password:
description: Password used by SOC to authenticate to the PostgreSQL server.
global: True
sensitive: True
advanced: True
salt: salt:
longRelayTimeoutMs: longRelayTimeoutMs:
description: Duration (in milliseconds) to wait for a response from the Salt API when executing tasks known for being long running before giving up and showing an error on the SOC UI. description: Duration (in milliseconds) to wait for a response from the Salt API when executing tasks known for being long running before giving up and showing an error on the SOC UI.
@@ -818,6 +854,7 @@ soc:
description: List of available external tools visible in the SOC UI. Each tool is defined in JSON object notation, and must include the "name" key and "link" key, where the link is the tool's URL. description: List of available external tools visible in the SOC UI. Each tool is defined in JSON object notation, and must include the "name" key and "link" key, where the link is the tool's URL.
global: True global: True
advanced: True advanced: True
multiline: True
forcedType: "[]{}" forcedType: "[]{}"
exportNodeId: exportNodeId:
description: The node ID on which export jobs will be executed. description: The node ID on which export jobs will be executed.
+1 -1
View File
@@ -261,7 +261,7 @@ strelka:
priority: 5 priority: 5
options: options:
limit: 1000 limit: 1000
'ScanLNK': 'ScanLnk':
- positive: - positive:
flavors: flavors:
- 'lnk_file' - 'lnk_file'
+1 -1
View File
@@ -15,7 +15,7 @@ from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler from watchdog.events import FileSystemEventHandler
with open("/opt/so/conf/strelka/filecheck.yaml", "r") as ymlfile: with open("/opt/so/conf/strelka/filecheck.yaml", "r") as ymlfile:
cfg = yaml.load(ymlfile, Loader=yaml.Loader) cfg = yaml.safe_load(ymlfile)
extract_path = cfg["filecheck"]["extract_path"] extract_path = cfg["filecheck"]["extract_path"]
historypath = cfg["filecheck"]["historypath"] historypath = cfg["filecheck"]["historypath"]
+1 -1
View File
@@ -99,7 +99,7 @@ strelka:
'ScanJpeg': *scannerOptions 'ScanJpeg': *scannerOptions
'ScanJson': *scannerOptions 'ScanJson': *scannerOptions
'ScanLibarchive': *scannerOptions 'ScanLibarchive': *scannerOptions
'ScanLNK': *scannerOptions 'ScanLnk': *scannerOptions
'ScanLsb': *scannerOptions 'ScanLsb': *scannerOptions
'ScanLzma': *scannerOptions 'ScanLzma': *scannerOptions
'ScanMacho': *scannerOptions 'ScanMacho': *scannerOptions
+1 -1
View File
@@ -1,6 +1,6 @@
telegraf: telegraf:
enabled: False enabled: False
output: BOTH output: INFLUXDB
config: config:
interval: '30s' interval: '30s'
metric_batch_size: 1000 metric_batch_size: 1000
+27 -2
View File
@@ -119,7 +119,7 @@ base:
- kafka - kafka
- pcap.cleanup - pcap.cleanup
'*_manager or *_managerhype and G@saltversion:{{saltversion}} and not I@node_data:False': '*_manager and G@saltversion:{{saltversion}} and not I@node_data:False':
- match: compound - match: compound
- salt.master - salt.master
- registry - registry
@@ -146,6 +146,32 @@ base:
- stig - stig
- kafka - kafka
'*_managerhype and G@saltversion:{{saltversion}} and not I@node_data:False':
- match: compound
- salt.master
- registry
- nginx
- influxdb
- postgres
- strelka.manager
- soc
- kratos
- hydra
- firewall
- manager
- sensoroni
- telegraf
- backup.config_backup
- elasticsearch
- logstash
- redis
- elastic-fleet-package-registry
- kibana
- elastalert
- utility
- elasticfleet
- kafka
'*_managerhype and I@features:vrt and G@saltversion:{{saltversion}}': '*_managerhype and I@features:vrt and G@saltversion:{{saltversion}}':
- match: compound - match: compound
- manager.hypervisor - manager.hypervisor
@@ -286,7 +312,6 @@ base:
- libvirt - libvirt
- libvirt.images - libvirt.images
- elasticfleet.install_agent_grid - elasticfleet.install_agent_grid
- stig
'*_desktop and G@saltversion:{{saltversion}}': '*_desktop and G@saltversion:{{saltversion}}':
- sensoroni - sensoroni
+90 -8
View File
@@ -539,16 +539,19 @@ configure_minion() {
" x509_v2: true"\ " x509_v2: true"\
"log_level: info"\ "log_level: info"\
"log_level_logfile: info"\ "log_level_logfile: info"\
"log_file: /opt/so/log/salt/minion"\ "log_file: /opt/so/log/salt/minion" >> "$minion_config"
"#startup_states: highstate" >> "$minion_config"
} }
checkin_at_boot() { mark_setup_complete() {
local minion_config=/etc/salt/minion # Writes the setup-complete marker. Salt's so-boot-highstate.service
# (boot-time oneshot) and the so-user_sync cron gate in
# salt/manager/sync_es_users.sls both key off this file.
local marker=/opt/so/state/setup-complete
info "Enabling checkin at boot" info "Marking setup as complete"
sed -i 's/#startup_states: highstate/startup_states: highstate/' "$minion_config" mkdir -p "$(dirname "$marker")"
touch "$marker"
} }
check_requirements() { check_requirements() {
@@ -745,6 +748,56 @@ configure_network_sensor() {
return $err return $err
} }
configure_management_bond() {
local bond_name="bond1"
local bond_mode=${MBOND_MODE:-active-backup}
info "Setting up $bond_name management interface with mode $bond_mode"
if [[ ${#MBNICS[@]} -eq 0 ]]; then
error "[ERROR] No management bond NICs were selected."
fail_setup
fi
nmcli -t -f NAME con show | grep -Fxq "$bond_name"
local found_int=$?
if [[ $found_int != 0 ]]; then
nmcli con add type bond ifname "$bond_name" con-name "$bond_name" mode "$bond_mode" -- \
ipv6.method ignore \
connection.autoconnect yes >> "$setup_log" 2>&1
else
nmcli con mod "$bond_name" \
bond.options "mode=$bond_mode" \
ipv6.method ignore \
connection.autoconnect yes >> "$setup_log" 2>&1
fi
local err=0
for MBNIC in "${MBNICS[@]}"; do
local slave_name="$bond_name-slave-$MBNIC"
nmcli -t -f NAME con show | grep -Fxq "$slave_name"
found_int=$?
if [[ $found_int != 0 ]]; then
nmcli con add type ethernet ifname "$MBNIC" con-name "$slave_name" master "$bond_name" -- \
connection.autoconnect yes >> "$setup_log" 2>&1
else
nmcli con mod "$slave_name" \
connection.master "$bond_name" \
connection.slave-type bond \
connection.autoconnect yes >> "$setup_log" 2>&1
fi
nmcli con up "$slave_name" >> "$setup_log" 2>&1
local ret=$?
[[ $ret -eq 0 ]] || err=$ret
done
return $err
}
configure_hyper_bridge() { configure_hyper_bridge() {
info "Setting up hypervisor bridge" info "Setting up hypervisor bridge"
info "Checking $MNIC ipv4.method is auto or manual" info "Checking $MNIC ipv4.method is auto or manual"
@@ -927,6 +980,8 @@ docker_seed_registry() {
docker_seed_update_percent=25 docker_seed_update_percent=25
update_docker_containers 'netinstall' '' 'docker_seed_update' '/dev/stdout' 2>&1 | tee -a "$setup_log" update_docker_containers 'netinstall' '' 'docker_seed_update' '/dev/stdout' 2>&1 | tee -a "$setup_log"
# Use pipe exit status of 'update_docker_containers' for return code
return ${PIPESTATUS[0]}
fi fi
} }
@@ -999,6 +1054,11 @@ filter_unused_nics() {
grep_string="$grep_string\|$BONDNIC" grep_string="$grep_string\|$BONDNIC"
done done
fi fi
if [[ $MBNICS ]]; then
for BONDNIC in "${MBNICS[@]}"; do
grep_string="$grep_string\|$BONDNIC"
done
fi
# Finally, set filtered_nics to any NICs we aren't using (and ignore interfaces that aren't of use) # Finally, set filtered_nics to any NICs we aren't using (and ignore interfaces that aren't of use)
filtered_nics=$(ip link | awk -F: '$0 !~ "lo|vir|veth|br|docker|wl|^[^0-9]"{print $2}' | grep -vwe "$grep_string" | sed 's/ //g' | sed -r 's/(.*)(\.[0-9]+)@\1/\1\2/g') filtered_nics=$(ip link | awk -F: '$0 !~ "lo|vir|veth|br|docker|wl|^[^0-9]"{print $2}' | grep -vwe "$grep_string" | sed 's/ //g' | sed -r 's/(.*)(\.[0-9]+)@\1/\1\2/g')
@@ -1388,7 +1448,7 @@ network_init() {
title "Initializing Network" title "Initializing Network"
disable_ipv6 disable_ipv6
set_hostname set_hostname
if [[ ( $is_iso || $is_desktop_iso ) ]]; then if [[ $is_iso || $is_desktop_iso ]]; then
set_management_interface set_management_interface
fi fi
} }
@@ -1701,6 +1761,24 @@ remove_package() {
fi fi
} }
ensure_pyyaml() {
title "Ensuring python3-pyyaml is installed"
if rpm -q python3-pyyaml >/dev/null 2>&1; then
info "python3-pyyaml already installed"
return 0
fi
info "python3-pyyaml not found, attempting to install"
set -o pipefail
dnf -y install python3-pyyaml 2>&1 | tee -a "$setup_log"
local result=$?
set +o pipefail
if [[ $result -ne 0 ]] || ! rpm -q python3-pyyaml >/dev/null 2>&1; then
error "Failed to install python3-pyyaml (exit=$result)"
fail_setup
fi
info "python3-pyyaml installed successfully"
}
# When updating the salt version, also update the version in securityonion-builds/images/iso-task/Dockerfile and salt/salt/master.defaults.yaml and salt/salt/minion.defaults.yaml # When updating the salt version, also update the version in securityonion-builds/images/iso-task/Dockerfile and salt/salt/master.defaults.yaml and salt/salt/minion.defaults.yaml
# CAUTION! SALT VERSION UDDATES - READ BELOW # CAUTION! SALT VERSION UDDATES - READ BELOW
# When updating the salt version, also update the version in: # When updating the salt version, also update the version in:
@@ -2084,8 +2162,12 @@ set_initial_firewall_access() {
# Set up the management interface on the ISO # Set up the management interface on the ISO
set_management_interface() { set_management_interface() {
title "Setting up the main interface" title "Setting up the main interface"
if [[ $MNIC == "bond1" ]]; then
configure_management_bond || fail_setup
fi
if [ "$address_type" = 'DHCP' ]; then if [ "$address_type" = 'DHCP' ]; then
logCmd "nmcli con mod $MNIC connection.autoconnect yes" logCmd "nmcli con mod $MNIC connection.autoconnect yes ipv4.method auto"
logCmd "nmcli con up $MNIC" logCmd "nmcli con up $MNIC"
logCmd "nmcli -p connection show $MNIC" logCmd "nmcli -p connection show $MNIC"
else else
+10 -2
View File
@@ -66,6 +66,9 @@ set_timezone
# Let's see what OS we are dealing with here # Let's see what OS we are dealing with here
detect_os detect_os
# Ensure python3-pyyaml is available before any code that may need so-yaml/PyYAML
ensure_pyyaml
# Check to see if this is the setup type of "desktop". # Check to see if this is the setup type of "desktop".
is_desktop= is_desktop=
@@ -220,6 +223,8 @@ if [ -n "$test_profile" ]; then
WEBPASSWD1=0n10nus3r WEBPASSWD1=0n10nus3r
WEBPASSWD2=0n10nus3r WEBPASSWD2=0n10nus3r
NODE_DESCRIPTION="${HOSTNAME} - ${install_type} - ${MSRVIP_OFFSET}" NODE_DESCRIPTION="${HOSTNAME} - ${install_type} - ${MSRVIP_OFFSET}"
# opt out of telemetry for automated testing
telemetry=1
update_sudoers_for_testing update_sudoers_for_testing
fi fi
@@ -764,7 +769,10 @@ if ! [[ -f $install_opt_file ]]; then
title "Applying the registry state" title "Applying the registry state"
logCmd "salt-call state.apply -l info registry" logCmd "salt-call state.apply -l info registry"
title "Seeding the docker registry" title "Seeding the docker registry"
docker_seed_registry if ! docker_seed_registry; then
error "Failed to seed the docker registry"
fail_setup
fi
title "Applying the manager state" title "Applying the manager state"
logCmd "salt-call state.apply -l info manager" logCmd "salt-call state.apply -l info manager"
logCmd "salt-call state.apply influxdb -l info" logCmd "salt-call state.apply influxdb -l info"
@@ -789,7 +797,7 @@ if ! [[ -f $install_opt_file ]]; then
error "Failed to run so-elastic-fleet-setup" error "Failed to run so-elastic-fleet-setup"
fail_setup fail_setup
fi fi
checkin_at_boot mark_setup_complete
set_initial_firewall_access set_initial_firewall_access
initialize_elasticsearch_indices "so-case so-casehistory so-assistant-session so-assistant-chat" initialize_elasticsearch_indices "so-case so-casehistory so-assistant-session so-assistant-chat"
# run a final highstate before enabling scheduled highstates. # run a final highstate before enabling scheduled highstates.
+83 -2
View File
@@ -845,18 +845,99 @@ whiptail_management_nic() {
[ -n "$TESTING" ] && return [ -n "$TESTING" ] && return
filter_unused_nics filter_unused_nics
local management_nic_options=( "${nic_list_management[@]}" )
if [[ $is_iso || $is_desktop_iso ]]; then
management_nic_options+=( "BOND" "Configure a bonded management interface" )
fi
MNIC=$(whiptail --title "$whiptail_title" --menu "Please select the NIC you would like to use for management.\n\nUse the arrow keys to move around and the Enter key to select." 20 75 12 "${nic_list_management[@]}" 3>&1 1>&2 2>&3 ) MNIC=$(whiptail --title "$whiptail_title" --menu "Please select the NIC you would like to use for management.\n\nUse the arrow keys to move around and the Enter key to select." 20 75 12 "${management_nic_options[@]}" 3>&1 1>&2 2>&3 )
local exitstatus=$? local exitstatus=$?
whiptail_check_exitstatus $exitstatus whiptail_check_exitstatus $exitstatus
while [ -z "$MNIC" ] while [ -z "$MNIC" ]
do do
MNIC=$(whiptail --title "$whiptail_title" --menu "Please select the NIC you would like to use for management.\n\nUse the arrow keys to move around and the Enter key to select." 22 75 12 "${nic_list_management[@]}" 3>&1 1>&2 2>&3 ) MNIC=$(whiptail --title "$whiptail_title" --menu "Please select the NIC you would like to use for management.\n\nUse the arrow keys to move around and the Enter key to select." 22 75 12 "${management_nic_options[@]}" 3>&1 1>&2 2>&3 )
local exitstatus=$? local exitstatus=$?
whiptail_check_exitstatus $exitstatus whiptail_check_exitstatus $exitstatus
done done
if [[ $MNIC == "BOND" ]]; then
whiptail_management_bond
fi
}
whiptail_management_bond() {
[ -n "$TESTING" ] && return
MBOND_MODE=$(whiptail --title "$whiptail_title" --menu \
"Choose the bond mode for the management interface.\n\nThe management bond will be created as bond1." 20 75 7 \
"active-backup" "One active NIC with failover (recommended)" \
"balance-rr" "Round-robin transmit policy" \
"balance-xor" "Transmit based on selected hash policy" \
"broadcast" "Transmit everything on all slave interfaces" \
"802.3ad" "Dynamic link aggregation (requires switch support)" \
"balance-tlb" "Adaptive transmit load balancing" \
"balance-alb" "Adaptive load balancing" 3>&1 1>&2 2>&3)
local exitstatus=$?
whiptail_check_exitstatus $exitstatus
while [ -z "$MBOND_MODE" ]
do
MBOND_MODE=$(whiptail --title "$whiptail_title" --menu \
"Choose the bond mode for the management interface.\n\nThe management bond will be created as bond1." 20 75 7 \
"active-backup" "One active NIC with failover (recommended)" \
"balance-rr" "Round-robin transmit policy" \
"balance-xor" "Transmit based on selected hash policy" \
"broadcast" "Transmit everything on all slave interfaces" \
"802.3ad" "Dynamic link aggregation (requires switch support)" \
"balance-tlb" "Adaptive transmit load balancing" \
"balance-alb" "Adaptive load balancing" 3>&1 1>&2 2>&3)
local exitstatus=$?
whiptail_check_exitstatus $exitstatus
done
whiptail_management_bond_nics
MNIC="bond1"
export MBOND_MODE MNIC
}
whiptail_management_bond_nics() {
[ -n "$TESTING" ] && return
MBNICS=()
filter_unused_nics
MBNICS=$(whiptail --title "$whiptail_title" --checklist "Please add NICs to the Management Interface:" 20 75 12 "${nic_list[@]}" 3>&1 1>&2 2>&3)
local exitstatus=$?
whiptail_check_exitstatus $exitstatus
while [ -z "$MBNICS" ]
do
MBNICS=$(whiptail --title "$whiptail_title" --checklist "Please add NICs to the Management Interface:" 20 75 12 "${nic_list[@]}" 3>&1 1>&2 2>&3)
local exitstatus=$?
whiptail_check_exitstatus $exitstatus
done
MBNICS=$(echo "$MBNICS" | tr -d '"')
IFS=' ' read -ra MBNICS <<< "$MBNICS"
for bond_nic in "${MBNICS[@]}"; do
for dev_status in "${nmcli_dev_status_list[@]}"; do
if [[ $dev_status == "${bond_nic}:unmanaged" ]]; then
whiptail \
--title "$whiptail_title" \
--msgbox "$bond_nic is unmanaged by Network Manager. Please remove it from other network management tools then re-run setup." \
8 75
exit
fi
done
done
export MBNICS
} }
whiptail_net_method() { whiptail_net_method() {
Binary file not shown.
Binary file not shown.