Compare commits

...

136 Commits

Author SHA1 Message Date
reyesj2 c505160480 set default DLM retention 90d 2026-06-11 15:13:28 -05:00
reyesj2 d9f6cde4e1 remove global setting from data_retention annotation 2026-06-11 15:11:29 -05:00
reyesj2 cf456dc58c reuse existing index templates 2026-06-09 23:21:43 -05:00
reyesj2 9aa9ea3255 Iniitial DLM support 2026-06-09 23:19:26 -05:00
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
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
Josh Patterson 1c6574c694 ensure minion ids 2026-05-04 14:03:14 -04:00
Mike Reeves 3a4b7b50de ensure python3-pyyaml is installed before continuing setup 2026-04-30 10:15:09 -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
66 changed files with 3157 additions and 353 deletions
+1
View File
@@ -11,6 +11,7 @@ body:
-
- 3.0.0
- 3.1.0
- 3.2.0
- Other (please provide detail below)
validations:
required: true
+11 -11
View File
@@ -1,17 +1,17 @@
### 3.0.0-20260331 ISO image released on 2026/03/31
### 3.1.0-20260528 ISO image released on 2026/05/28
### Download and Verify
3.0.0-20260331 ISO image:
https://download.securityonion.net/file/securityonion/securityonion-3.0.0-20260331.iso
3.1.0-20260528 ISO image:
https://download.securityonion.net/file/securityonion/securityonion-3.1.0-20260528.iso
MD5: ECD318A1662A6FDE0EF213F5A9BD4B07
SHA1: E55BE314440CCF3392DC0B06BC5E270B43176D9C
SHA256: 7FC47405E335CBE5C2B6C51FE7AC60248F35CBE504907B8B5A33822B23F8F4D5
MD5: 9D6FF58DEEE24089D722C73169765B3E
SHA1: 2B8B816B6CEC3B7F96B3C5E040EBF502DD2C412F
SHA256: 62FAB57E247C843D6A04F0796D8162C732B65D82FC3E4A59D087135B9FD32912
Signature for ISO image:
https://github.com/Security-Onion-Solutions/securityonion/raw/3/main/sigs/securityonion-3.0.0-20260331.iso.sig
https://github.com/Security-Onion-Solutions/securityonion/raw/3/main/sigs/securityonion-3.1.0-20260528.iso.sig
Signing key:
https://raw.githubusercontent.com/Security-Onion-Solutions/securityonion/3/main/KEYS
@@ -25,22 +25,22 @@ wget https://raw.githubusercontent.com/Security-Onion-Solutions/securityonion/3/
Download the signature file for the ISO:
```
wget https://github.com/Security-Onion-Solutions/securityonion/raw/3/main/sigs/securityonion-3.0.0-20260331.iso.sig
wget https://github.com/Security-Onion-Solutions/securityonion/raw/3/main/sigs/securityonion-3.1.0-20260528.iso.sig
```
Download the ISO image:
```
wget https://download.securityonion.net/file/securityonion/securityonion-3.0.0-20260331.iso
wget https://download.securityonion.net/file/securityonion/securityonion-3.1.0-20260528.iso
```
Verify the downloaded ISO image using the signature file:
```
gpg --verify securityonion-3.0.0-20260331.iso.sig securityonion-3.0.0-20260331.iso
gpg --verify securityonion-3.1.0-20260528.iso.sig securityonion-3.1.0-20260528.iso
```
The output should show "Good signature" and the Primary key fingerprint should match what's shown below:
```
gpg: Signature made Mon 30 Mar 2026 06:22:14 PM EDT using RSA key ID FE507013
gpg: Signature made Wed 27 May 2026 03:03:59 PM EDT using RSA key ID FE507013
gpg: Good signature from "Security Onion Solutions, LLC <info@securityonionsolutions.com>"
gpg: WARNING: This key is not certified with a trusted signature!
gpg: There is no indication that the signature belongs to the owner.
+1
View File
@@ -0,0 +1 @@
+1 -1
View File
@@ -1 +1 @@
3.1.0
3.2.0
@@ -25,9 +25,11 @@ if [ ! -f $BACKUPFILE ]; then
# Create empty backup file
tar -cf $BACKUPFILE -T /dev/null
# Loop through all paths defined in global.sls, and append them to backup file
# Loop through all paths defined in global.sls, and append them to backup file if they exist
{%- for LOCATION in BACKUPLOCATIONS %}
tar -rf $BACKUPFILE "${EXCLUSIONS[@]}" {{ LOCATION }}
if [[ -d {{ LOCATION }} || -f {{ LOCATION }} ]]; then
tar -rf $BACKUPFILE "${EXCLUSIONS[@]}" {{ LOCATION }}
fi
{%- endfor %}
fi
+5
View File
@@ -142,6 +142,11 @@ check_elastic_license() {
fi
}
check_elasticsearch_responsive() {
retry 3 15 "so-elasticsearch-query / --output /dev/null --fail" ||
fail "Elasticsearch is not responding. Please review Elasticsearch logs /opt/so/log/elasticsearch/securityonion.log for more details. Additionally, consider running so-elasticsearch-troubleshoot."
}
check_salt_master_status() {
local count=0
local attempts="${1:- 10}"
+2
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 composable template" # false positive (elasticsearch composable template names contain 'error')
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|Error while parsing document for index \[.ds-logs-kratos-so-.*object mapping for \[file\]" # false positive (mapping error occuring BEFORE kratos index has rolled over in 2.4.210)
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|No such container" # false positive (telegraf trying to run stats on an old container)
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|passwords do not match" # false positive (automated hydra test)
fi
if [[ $EXCLUDE_KNOWN_ERRORS == 'Y' ]]; then
@@ -9,7 +9,6 @@
{% set CORE_ESFLEET_PACKAGES = ELASTICFLEETDEFAULTS.get('elasticfleet', {}).get('packages', {}) %}
{% set ADDON_CONTENT_INTEGRATION_DEFAULTS = {} %}
{% set DEBUG_STUFF = {} %}
{% for pkg in ADDON_CONTENT_PACKAGE_COMPONENTS %}
{% if pkg.name in CORE_ESFLEET_PACKAGES %}
+2
View File
@@ -26,7 +26,9 @@ include:
wait_for_elasticsearch_elasticfleet:
cmd.run:
- name: so-elasticsearch-wait
{% endif %}
{% if GLOBALS.role == "so-fleet" %}
# Sync Elastic Agent artifacts to Fleet Node
elasticagent_syncartifacts:
file.recurse:
@@ -9,7 +9,6 @@
{% set CORE_ESFLEET_PACKAGES = ELASTICFLEETDEFAULTS.get('elasticfleet', {}).get('packages', {}) %}
{% set ADDON_INPUT_INTEGRATION_DEFAULTS = {} %}
{% set DEBUG_STUFF = {} %}
{% for pkg in ADDON_INPUT_PACKAGE_COMPONENTS %}
{% if pkg.name in CORE_ESFLEET_PACKAGES %}
@@ -116,7 +115,6 @@
{% do ADDON_INPUT_INTEGRATION_DEFAULTS.update({integration_key: integration_defaults}) %}
{% do DEBUG_STUFF.update({integration_key: "Generating defaults for "+ pkg.name })%}
{% endfor %}
{% endif %}
{% endif %}
+4 -2
View File
@@ -11,14 +11,15 @@ include:
- elasticfleet.config
# If enabled, automatically update Fleet Logstash Outputs
{% if ELASTICFLEETMERGED.config.server.enable_auto_configuration and grains.role not in ['so-import', 'so-eval'] %}
{% if ELASTICFLEETMERGED.config.server.enable_auto_configuration %}
{% if grains.role not in ['so-import', 'so-eval']%}
so-elastic-fleet-auto-configure-logstash-outputs:
cmd.run:
- name: /usr/sbin/so-elastic-fleet-outputs-update
- retry:
attempts: 4
interval: 30
{% endif %}
{% endif %}
# If enabled, automatically update Fleet Server URLs & ES Connection
so-elastic-fleet-auto-configure-server-urls:
@@ -27,6 +28,7 @@ so-elastic-fleet-auto-configure-server-urls:
- retry:
attempts: 4
interval: 30
{% endif %}
# Automatically update Fleet Server Elasticsearch URLs & Agent Artifact URLs
so-elastic-fleet-auto-configure-elasticsearch-urls:
+30 -1
View File
@@ -9,9 +9,12 @@
{% from 'elasticsearch/config.map.jinja' import ELASTICSEARCHMERGED %}
{% from 'elasticsearch/template.map.jinja' import ES_INDEX_SETTINGS, SO_MANAGED_INDICES %}
{% if GLOBALS.role != 'so-heavynode' %}
{% from 'elasticsearch/template.map.jinja' import ALL_ADDON_SETTINGS %}
{% from 'elasticsearch/template.map.jinja' import ALL_ADDON_SETTINGS, ADDON_INDICES %}
{% endif %}
include:
- elasticsearch.enabled
escomponenttemplates:
file.recurse:
- name: /opt/so/conf/elasticsearch/templates/component
@@ -35,6 +38,20 @@ so_index_template_dir:
{%- endfor %}
{%- endif %}
{% if GLOBALS.role != "so-heavynode" %}
# Clean up legacy and non-SO managed templates from the elasticsearch/templates/addon-index/ directory
addon_index_template_dir:
file.directory:
- name: /opt/so/conf/elasticsearch/templates/addon-index
- clean: True
{%- if ADDON_INDICES %}
- require:
{%- for index in ADDON_INDICES %}
- file: addon_index_template_{{index}}
{%- endfor %}
{%- endif %}
{% endif %}
# Auto-generate index templates for SO managed indices (directly defined in elasticsearch/defaults.yaml)
# These index templates are for the core SO datasets and are always required
{% for index, settings in ES_INDEX_SETTINGS.items() %}
@@ -116,6 +133,18 @@ so-elasticsearch-templates:
- docker_container: so-elasticsearch
- file: elasticsearch_sbin_jinja
so-elasticsearch-dlm-apply:
cmd.run:
- name: /usr/sbin/so-elasticsearch-dlm-apply
- cwd: /opt/so
- require:
- docker_container: so-elasticsearch
- file: elasticsearch_sbin_jinja
- cmd: so-elasticsearch-templates
- retry:
attempts: 3
interval: 10
so-elasticsearch-pipelines:
cmd.run:
- name: /usr/sbin/so-elasticsearch-pipelines {{ GLOBALS.hostname }}
+137
View File
@@ -2,6 +2,7 @@ elasticsearch:
enabled: false
version: 9.3.3
index_clean: true
data_retention_method: DLM
vm:
max_map_count: 1048576
config:
@@ -18,9 +19,18 @@ elasticsearch:
flood_stage: 90%
high: 85%
low: 80%
# don't want to set retention here since it will make ES restart with every update +
# potentially case where we could unintentially fall back to retention 7d and cause data loss
# data_streams:
# lifecycle:
# retention:
# default: 7d
indices:
id_field_data:
enabled: false
# index:
# lifecycle:
# prefer_ilm: true
logger:
org:
elasticsearch:
@@ -63,6 +73,9 @@ elasticsearch:
verification_mode: none
index_settings:
global_overrides:
# Tie this into cluster setting for data_streams.lifecycle.retention.default
data_stream_lifecycle:
data_retention: 90d
index_template:
template:
settings:
@@ -143,6 +156,8 @@ elasticsearch:
order: desc
so-common:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- agent-mappings
@@ -304,6 +319,8 @@ elasticsearch:
number_of_shards: 1
so-assistant-chat:
index_sorting: false
data_stream_lifecycle:
data_retention: ""
index_template:
composed_of:
- assistant-chat-mappings
@@ -344,6 +361,8 @@ elasticsearch:
min_age: 0ms
so-assistant-session:
index_sorting: false
data_stream_lifecycle:
data_retention: ""
index_template:
composed_of:
- assistant-session-mappings
@@ -497,6 +516,8 @@ elasticsearch:
min_age: 30d
so-idh:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- agent-mappings
@@ -605,6 +626,8 @@ elasticsearch:
min_age: 30d
so-import:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- agent-mappings
@@ -787,6 +810,8 @@ elasticsearch:
min_age: 0ms
so-kismet:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- kismet-mappings
@@ -836,6 +861,8 @@ elasticsearch:
min_age: 30d
so-kratos:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- agent-mappings
@@ -904,6 +931,8 @@ elasticsearch:
min_age: 30d
so-hydra:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- agent-mappings
@@ -1049,6 +1078,8 @@ elasticsearch:
min_age: 0ms
so-logs:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- so-data-streams-mappings
@@ -1129,6 +1160,8 @@ elasticsearch:
min_age: 30d
so-logs-detections_x_alerts:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- so-data-streams-mappings
@@ -1192,6 +1225,8 @@ elasticsearch:
min_age: 30d
so-logs-elastic_agent:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- event-mappings
@@ -1307,6 +1342,8 @@ elasticsearch:
min_age: 30d
so-elastic-agent-monitor:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- event-mappings
@@ -1369,6 +1406,8 @@ elasticsearch:
min_age: 30d
so-logs-elastic_agent_x_apm_server:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-elastic_agent.apm_server@package
@@ -1433,6 +1472,8 @@ elasticsearch:
min_age: 30d
so-logs-elastic_agent_x_auditbeat:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-elastic_agent.auditbeat@package
@@ -1497,6 +1538,8 @@ elasticsearch:
min_age: 30d
so-logs-elastic_agent_x_cloudbeat:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-elastic_agent.cloudbeat@package
@@ -1561,6 +1604,8 @@ elasticsearch:
min_age: 30d
so-logs-elastic_agent_x_endpoint_security:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- event-mappings
@@ -1620,6 +1665,8 @@ elasticsearch:
min_age: 30d
so-logs-elastic_agent_x_filebeat:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- event-mappings
@@ -1679,6 +1726,8 @@ elasticsearch:
min_age: 30d
so-logs-elastic_agent_x_fleet_server:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- event-mappings
@@ -1735,6 +1784,8 @@ elasticsearch:
min_age: 30d
so-logs-elastic_agent_x_heartbeat:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-elastic_agent.heartbeat@package
@@ -1799,6 +1850,8 @@ elasticsearch:
min_age: 30d
so-logs-elastic_agent_x_metricbeat:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- event-mappings
@@ -1858,6 +1911,8 @@ elasticsearch:
min_age: 30d
so-logs-elastic_agent_x_osquerybeat:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- event-mappings
@@ -1917,6 +1972,8 @@ elasticsearch:
min_age: 30d
so-logs-elastic_agent_x_packetbeat:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-elastic_agent.packetbeat@package
@@ -1981,6 +2038,8 @@ elasticsearch:
min_age: 30d
so-logs-elasticsearch_x_server:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-elasticsearch.server@package
@@ -2045,6 +2104,8 @@ elasticsearch:
min_age: 30d
so-logs-endpoint_x_actions:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- .logs-endpoint.actions@package
@@ -2104,6 +2165,8 @@ elasticsearch:
min_age: 30d
so-logs-endpoint_x_action_x_responses:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- .logs-endpoint.action.responses@package
@@ -2163,6 +2226,8 @@ elasticsearch:
min_age: 30d
so-logs-endpoint_x_alerts:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-endpoint.alerts@package
@@ -2222,6 +2287,8 @@ elasticsearch:
min_age: 30d
so-logs-endpoint_x_diagnostic_x_collection:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- .logs-endpoint.diagnostic.collection@package
@@ -2297,6 +2364,8 @@ elasticsearch:
min_age: 30d
so-logs-endpoint_x_events_x_api:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-endpoint.events.api@package
@@ -2356,6 +2425,8 @@ elasticsearch:
min_age: 30d
so-logs-endpoint_x_events_x_file:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-endpoint.events.file@package
@@ -2415,6 +2486,8 @@ elasticsearch:
min_age: 30d
so-logs-endpoint_x_events_x_library:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-endpoint.events.library@package
@@ -2474,6 +2547,8 @@ elasticsearch:
min_age: 30d
so-logs-endpoint_x_events_x_network:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-endpoint.events.network@package
@@ -2533,6 +2608,8 @@ elasticsearch:
min_age: 30d
so-logs-endpoint_x_events_x_process:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-endpoint.events.process@package
@@ -2592,6 +2669,8 @@ elasticsearch:
min_age: 30d
so-logs-endpoint_x_events_x_registry:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-endpoint.events.registry@package
@@ -2651,6 +2730,8 @@ elasticsearch:
min_age: 30d
so-logs-endpoint_x_events_x_security:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-endpoint.events.security@package
@@ -2710,6 +2791,8 @@ elasticsearch:
min_age: 30d
so-logs-endpoint_x_heartbeat:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- .logs-endpoint.heartbeat@package
@@ -2769,6 +2852,8 @@ elasticsearch:
min_age: 30d
so-logs-http_endpoint_x_generic:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-http_endpoint.generic@package
@@ -2817,6 +2902,8 @@ elasticsearch:
min_age: 30d
so-logs-httpjson_x_generic:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-httpjson.generic@package
@@ -2882,6 +2969,8 @@ elasticsearch:
number_of_replicas: 0
so-logs-osquery-manager_x_action_x_responses:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
_meta:
managed: true
@@ -2953,6 +3042,8 @@ elasticsearch:
number_of_replicas: 0
so-logs-osquery-manager_x_result:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
_meta:
managed: true
@@ -3005,6 +3096,8 @@ elasticsearch:
min_age: 30d
so-logs-soc:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- agent-mappings
@@ -3113,6 +3206,8 @@ elasticsearch:
min_age: 30d
so-logs-system_x_application:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- event-mappings
@@ -3162,6 +3257,8 @@ elasticsearch:
min_age: 30d
so-logs-system_x_auth:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- event-mappings
@@ -3211,6 +3308,8 @@ elasticsearch:
min_age: 30d
so-logs-system_x_security:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- event-mappings
@@ -3260,6 +3359,8 @@ elasticsearch:
min_age: 30d
so-logs-system_x_syslog:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- event-mappings
@@ -3309,6 +3410,8 @@ elasticsearch:
min_age: 30d
so-logs-system_x_system:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- event-mappings
@@ -3358,6 +3461,8 @@ elasticsearch:
min_age: 30d
so-logs-windows_x_forwarded:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-windows.forwarded@package
@@ -3405,6 +3510,8 @@ elasticsearch:
min_age: 30d
so-logs-windows_x_powershell:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-windows.powershell@package
@@ -3452,6 +3559,8 @@ elasticsearch:
min_age: 30d
so-logs-windows_x_powershell_operational:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-windows.powershell_operational@package
@@ -3499,6 +3608,8 @@ elasticsearch:
min_age: 30d
so-logs-windows_x_sysmon_operational:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-windows.sysmon_operational@package
@@ -3546,6 +3657,8 @@ elasticsearch:
min_age: 30d
so-logs-winlog_x_winlog:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-winlog.winlog@package
@@ -3594,6 +3707,8 @@ elasticsearch:
min_age: 30d
so-logstash:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- agent-mappings
@@ -3709,6 +3824,8 @@ elasticsearch:
min_age: 30d
so-metrics-endpoint_x_metadata:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- metrics-endpoint.metadata@package
@@ -3756,6 +3873,8 @@ elasticsearch:
min_age: 30d
so-metrics-endpoint_x_metrics:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- metrics-endpoint.metrics@package
@@ -3803,6 +3922,8 @@ elasticsearch:
min_age: 30d
so-metrics-endpoint_x_policy:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- metrics-endpoint.policy@package
@@ -3850,6 +3971,8 @@ elasticsearch:
min_age: 30d
so-metrics-fleet_server_x_agent_status:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- metrics@tsdb-settings
@@ -3874,6 +3997,8 @@ elasticsearch:
number_of_replicas: 0
so-metrics-fleet_server_x_agent_versions:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- metrics@tsdb-settings
@@ -3898,6 +4023,8 @@ elasticsearch:
number_of_replicas: 0
so-redis:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- agent-mappings
@@ -4013,6 +4140,8 @@ elasticsearch:
min_age: 30d
so-strelka:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- agent-mappings
@@ -4130,6 +4259,8 @@ elasticsearch:
min_age: 30d
so-suricata:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- agent-mappings
@@ -4246,6 +4377,8 @@ elasticsearch:
min_age: 30d
so-suricata_x_alerts:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- agent-mappings
@@ -4362,6 +4495,8 @@ elasticsearch:
min_age: 30d
so-syslog:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- agent-mappings
@@ -4478,6 +4613,8 @@ elasticsearch:
min_age: 30d
so-zeek:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- agent-mappings
+2 -1
View File
@@ -63,7 +63,8 @@
{ "set": { "if": "ctx.event?.dataset != null && !ctx.event.dataset.contains('.')", "field": "event.dataset", "value": "{{event.module}}.{{event.dataset}}" } },
{ "split": { "if": "ctx.event?.dataset != null && ctx.event.dataset.contains('.')", "field": "event.dataset", "separator": "\\.", "target_field": "dataset_tag_temp" } },
{ "append": { "if": "ctx.dataset_tag_temp != null", "field": "tags", "value": "{{dataset_tag_temp.1}}" } },
{ "grok": { "if": "ctx.http?.response?.status_code != null", "field": "http.response.status_code", "patterns": ["%{NUMBER:http.response.status_code:long} %{GREEDYDATA}"]} },
{ "grok": { "if": "ctx.http?.response?.status_code instanceof String", "field": "http.response.status_code", "patterns": ["%{NUMBER:http.response.status_code:long}(?:\\s+%{GREEDYDATA})?"], "ignore_failure": true } },
{ "convert": { "if": "ctx.http?.response?.status_code != null && !(ctx.http.response.status_code instanceof Number)", "field": "http.response.status_code", "type": "long", "ignore_failure": true } },
{ "set": { "if": "ctx?.metadata?.kafka != null" , "field": "kafka.id", "value": "{{metadata.kafka.partition}}{{metadata.kafka.offset}}{{metadata.kafka.timestamp}}", "ignore_failure": true } },
{ "remove": { "field": [ "message2", "type", "fields", "category", "module", "dataset", "dataset_tag_temp", "event.dataset_temp" ], "ignore_missing": true, "ignore_failure": true } },
{ "pipeline": { "name": "global@custom", "ignore_missing_pipeline": true, "description": "[Fleet] Global pipeline for all data streams" } }
+75 -2
View File
@@ -177,12 +177,84 @@
"description": "Extract IPs from Elastic Agent events (host.ip) and adds them to related.ip"
}
},
{
"script": {
"description": "Snapshot event.ingested into _tmp.event_ingested_pre_fleet before .fleet_final_pipeline-1 overwrites it with ES ingest time",
"lang": "painless",
"if": "ctx.event?.ingested != null && ctx.event?.created == null",
"ignore_failure": true,
"source": "ctx.putIfAbsent('_tmp', [:]); ctx._tmp.event_ingested_pre_fleet = ctx.event.ingested;"
}
},
{
"pipeline": {
"name": ".fleet_final_pipeline-1",
"ignore_missing_pipeline": true
}
},
{
"script": {
"description": "Calculate time from Elastic Agent to Logstash.",
"lang": "painless",
"if": "ctx._tmp?.logstash_from_agent != null",
"ignore_failure": true,
"source": "ZonedDateTime start = ctx._tmp.event_ingested_pre_fleet != null ? ZonedDateTime.parse(ctx._tmp.event_ingested_pre_fleet) : ZonedDateTime.parse(ctx['@timestamp']); ctx.event.putIfAbsent('ingestion', [:]); ctx.event.ingestion.latency_elasticagent_to_logstash = ChronoUnit.SECONDS.between(start, ZonedDateTime.parse(ctx._tmp.logstash_from_agent));"
}
},
{
"script": {
"description": "Calculate time from Logstash to Redis",
"lang": "painless",
"if": "ctx._tmp?.logstash_from_agent != null && ctx._tmp?.logstash_to_redis != null",
"ignore_failure": true,
"source": "ctx.event.putIfAbsent('ingestion', [:]); ctx.event.ingestion.latency_logstash_to_redis = ChronoUnit.SECONDS.between(ZonedDateTime.parse(ctx._tmp.logstash_from_agent), ZonedDateTime.parse(ctx._tmp.logstash_to_redis));"
}
},
{
"script": {
"description": "Calculate time message spends in redis queue (logstash delay in pulling event).",
"lang": "painless",
"if": "ctx._tmp?.logstash_to_redis != null && ctx._tmp?.logstash_from_redis != null",
"ignore_failure": true,
"source": "ctx.event.putIfAbsent('ingestion', [:]); ctx.event.ingestion.latency_redis_to_logstash = ChronoUnit.SECONDS.between(ZonedDateTime.parse(ctx._tmp.logstash_to_redis), ZonedDateTime.parse(ctx._tmp.logstash_from_redis));"
}
},
{
"script": {
"description": "Calculate time from Logstash to Elasticsearch (after read from Redis).",
"lang": "painless",
"if": "ctx._tmp?.logstash_from_redis != null",
"ignore_failure": true,
"source": "ctx.event.putIfAbsent('ingestion', [:]); ctx.event.ingestion.latency_logstash_to_elasticsearch = ChronoUnit.SECONDS.between(ZonedDateTime.parse(ctx._tmp.logstash_from_redis), metadata().now);"
}
},
{
"script": {
"description": "Calculate time from Elastic Agent to Kafka.",
"lang": "painless",
"if": "ctx._tmp?.logstash_from_kafka != null && ctx._tmp?.logstash_from_agent == null",
"ignore_failure": true,
"source": "ZonedDateTime start = ctx._tmp.event_ingested_pre_fleet != null ? ZonedDateTime.parse(ctx._tmp.event_ingested_pre_fleet) : ZonedDateTime.parse(ctx['@timestamp']); ctx.event.putIfAbsent('ingestion', [:]); ctx.event.ingestion.latency_elasticagent_to_kafka = ChronoUnit.SECONDS.between(start, ZonedDateTime.parse(ctx._tmp.logstash_from_kafka));"
}
},
{
"script": {
"description": "Calculate time message spends in Kafka queue (logstash delay in pulling event).",
"lang": "painless",
"if": "ctx._tmp?.logstash_from_kafka != null && ctx.metadata?.kafka?.timestamp != null && ctx._tmp?.logstash_from_agent == null",
"ignore_failure": true,
"source": "ctx.event.putIfAbsent('ingestion', [:]); ctx.event.ingestion.latency_kafka_queue = ChronoUnit.SECONDS.between(ZonedDateTime.ofInstant(Instant.ofEpochMilli(Long.parseLong(ctx.metadata.kafka.timestamp.toString())), ZoneId.of('UTC')), ZonedDateTime.parse(ctx._tmp.logstash_from_kafka));"
}
},
{
"script": {
"description": "Calculate time from Logstash to Elasticsearch (after read from Kafka).",
"lang": "painless",
"if": "ctx._tmp?.logstash_from_kafka != null && ctx._tmp?.logstash_from_agent == null",
"ignore_failure": true,
"source": "ctx.event.putIfAbsent('ingestion', [:]); ctx.event.ingestion.latency_kafka_to_elasticsearch = ChronoUnit.SECONDS.between(ZonedDateTime.parse(ctx._tmp.logstash_from_kafka), metadata().now);"
}
},
{
"remove": {
"field": "event.agent_id_status",
@@ -202,11 +274,12 @@
"event.dataset_temp",
"dataset_tag_temp",
"module_temp",
"datastream_dataset_temp"
"datastream_dataset_temp",
"_tmp"
],
"ignore_missing": 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"
}
}
]
}
+390 -61
View File
@@ -4,6 +4,13 @@ elasticsearch:
forcedType: bool
advanced: True
helpLink: elasticsearch
data_retention_method:
description: Method for data retention. Options are ILM or DLM. For single node deployments and most distributed grid users, DLM will be the recommended option for simplified management. Those with more complex use cases may prefer ILM. The latter allows for more granular control, but requires more management overhead.
options:
- ILM
- DLM
forcedType: string
global: True
version:
description: "This specifies the version of the following containers: so-elastic-fleet-package-registry, so-elastic-agent, so-elastic-fleet, so-kibana, so-logstash and so-elasticsearch. Modifying this value in the Elasticsearch defaults.yaml will result in catastrophic grid failure."
readonly: True
@@ -13,7 +20,7 @@ elasticsearch:
description: Specify the memory heap size in (m)egabytes for Elasticsearch.
helpLink: elasticsearch
index_clean:
description: Determines if indices should be considered for deletion by available disk space in the cluster. Otherwise, indices will only be deleted by the age defined in the ILM settings. This setting only applies to EVAL, STANDALONE, and HEAVY NODE installations. Other installations can only use ILM settings.
description: Determines if indices should be considered for deletion by available disk space in the cluster. Otherwise, data is retained by the configured lifecycle settings. This setting only applies to EVAL, STANDALONE, and HEAVY NODE installations. Other installations use lifecycle settings only.
forcedType: bool
helpLink: elasticsearch
vm:
@@ -139,6 +146,21 @@ elasticsearch:
custom010: *pipelines
index_settings:
global_overrides:
data_stream_lifecycle:
data_retention:
description: |
The retention period for all data streams. Retention does not define the period that the data will be removed, but the minimum time period they will be kept.
Use a number followed by a time unit, such as 7d. Leave blank for indefinite retention where supported.
Configured retention period also affects the frequency of rolling over data streams.
- If retention is less than or equal to 1 day, max_age will be 1 hour
- If retention is less than or equal to 14 days, max_age will be 1 day
- If retention is less than or equal to 90 days, max_age will be 7 days
- If retention is greater than 90 days, max_age will be 30 days
forcedType: string
regex: ^$|^[0-9]{1,5}(?:d|h|m|s)$
regexFailureMessage: Must be blank or a number followed by d, h, m, or s, such as 7d.
index_template:
template:
settings:
@@ -311,13 +333,28 @@ elasticsearch:
forcedType: string
global: True
helpLink: elasticsearch
so-logs: &indexSettings
so-logs: &dataStreamSettings
index_sorting:
description: Sorts the index by event time, at the cost of additional processing resource consumption.
forcedType: bool
global: True
advanced: True
helpLink: elasticsearch
data_stream_lifecycle:
data_retention:
description: |
The retention period for this data stream. Retention does not define the period that the data will be removed, but the minimum time period it will be kept.
Use a number followed by a time unit, such as 7d. Leave blank for indefinite retention where supported.
Configured retention period also affects the frequency of rolling over this data stream.
- If retention is less than or equal to 1 day, max_age will be 1 hour
- If retention is less than or equal to 14 days, max_age will be 1 day
- If retention is less than or equal to 90 days, max_age will be 7 days
- If retention is greater than 90 days, max_age will be 30 days
forcedType: string
regex: ^$|^[0-9]{1,5}(?:d|h|m|s)$
regexFailureMessage: Must be blank or a number followed by d, h, m, or s, such as 7d.
index_template:
index_patterns:
description: Patterns for matching multiple indices or tables.
@@ -335,6 +372,14 @@ elasticsearch:
global: True
advanced: True
helpLink: elasticsearch
auto_expand_replicas:
description: Automatically expand the number of replicas based on the number of data nodes in the cluster. This can help ensure high availability as the cluster scales up or down.
forcedType: string
regex: "^(0-[1-9]|1-[2-9]|2-[3-9]|3-[4-9]|4-[5-9]|5-[6-9]|6-[7-9]|7-[89]|8-9|[0-9]-all|false)$"
regexFailureMessage: Must be in the format of "x-y" where x is minimum number of replicas and y is maximum number of replicas, or "0-all" to specify a minimum of 0 and no maximum, or "false" to disable automatic replica expansion.
global: True
advanced: True
helpLink: elasticsearch
mapping:
total_fields:
limit:
@@ -596,65 +641,349 @@ elasticsearch:
global: True
advanced: True
helpLink: elasticsearch
so-logs-system_x_auth: *indexSettings
so-logs-system_x_syslog: *indexSettings
so-logs-system_x_system: *indexSettings
so-logs-system_x_application: *indexSettings
so-logs-system_x_security: *indexSettings
so-logs-windows_x_forwarded: *indexSettings
so-logs-windows_x_powershell: *indexSettings
so-logs-windows_x_powershell_operational: *indexSettings
so-logs-windows_x_sysmon_operational: *indexSettings
so-logs-winlog_x_winlog: *indexSettings
so-logs-detections_x_alerts: *indexSettings
so-logs-http_endpoint_x_generic: *indexSettings
so-logs-httpjson_x_generic: *indexSettings
so-logs-osquery-manager-actions: *indexSettings
so-logs-osquery-manager-action_x_responses: *indexSettings
so-logs-osquery-manager_x_action_x_responses: *indexSettings
so-logs-osquery-manager_x_result: *indexSettings
so-logs-elastic_agent_x_apm_server: *indexSettings
so-logs-elastic_agent_x_auditbeat: *indexSettings
so-logs-elastic_agent_x_cloudbeat: *indexSettings
so-logs-elastic_agent_x_endpoint_security: *indexSettings
so-logs-endpoint_x_alerts: *indexSettings
so-logs-endpoint_x_events_x_api: *indexSettings
so-logs-endpoint_x_events_x_file: *indexSettings
so-logs-endpoint_x_events_x_library: *indexSettings
so-logs-endpoint_x_events_x_network: *indexSettings
so-logs-endpoint_x_events_x_process: *indexSettings
so-logs-endpoint_x_events_x_registry: *indexSettings
so-logs-endpoint_x_events_x_security: *indexSettings
so-logs-elastic_agent_x_filebeat: *indexSettings
so-logs-elastic_agent_x_fleet_server: *indexSettings
so-logs-elastic_agent_x_heartbeat: *indexSettings
so-logs-elastic_agent: *indexSettings
so-logs-elastic_agent_x_metricbeat: *indexSettings
so-logs-elastic_agent_x_osquerybeat: *indexSettings
so-logs-elastic_agent_x_packetbeat: *indexSettings
so-logs-elasticsearch_x_server: *indexSettings
so-metrics-endpoint_x_metadata: *indexSettings
so-metrics-endpoint_x_metrics: *indexSettings
so-metrics-endpoint_x_policy: *indexSettings
so-metrics-nginx_x_stubstatus: *indexSettings
so-metrics-vsphere_x_datastore: *indexSettings
so-metrics-vsphere_x_host: *indexSettings
so-metrics-vsphere_x_virtualmachine: *indexSettings
so-case: *indexSettings
so-common: *indexSettings
so-endgame: *indexSettings
so-idh: *indexSettings
so-suricata: *indexSettings
so-suricata_x_alerts: *indexSettings
so-import: *indexSettings
so-kratos: *indexSettings
so-hydra: *indexSettings
so-kismet: *indexSettings
so-logstash: *indexSettings
so-redis: *indexSettings
so-strelka: *indexSettings
so-syslog: *indexSettings
so-zeek: *indexSettings
so-logs-system_x_auth: *dataStreamSettings
so-logs-system_x_syslog: *dataStreamSettings
so-logs-system_x_system: *dataStreamSettings
so-logs-system_x_application: *dataStreamSettings
so-logs-system_x_security: *dataStreamSettings
so-logs-windows_x_forwarded: *dataStreamSettings
so-logs-windows_x_powershell: *dataStreamSettings
so-logs-windows_x_powershell_operational: *dataStreamSettings
so-logs-windows_x_sysmon_operational: *dataStreamSettings
so-logs-winlog_x_winlog: *dataStreamSettings
so-logs-detections_x_alerts: *dataStreamSettings
so-logs-http_endpoint_x_generic: *dataStreamSettings
so-logs-httpjson_x_generic: *dataStreamSettings
so-logs-osquery-manager-actions: *dataStreamSettings
so-logs-osquery-manager-action_x_responses: *dataStreamSettings
so-logs-osquery-manager_x_action_x_responses: *dataStreamSettings
so-logs-osquery-manager_x_result: *dataStreamSettings
so-logs-elastic_agent_x_apm_server: *dataStreamSettings
so-logs-elastic_agent_x_auditbeat: *dataStreamSettings
so-logs-elastic_agent_x_cloudbeat: *dataStreamSettings
so-logs-elastic_agent_x_endpoint_security: *dataStreamSettings
so-logs-endpoint_x_alerts: *dataStreamSettings
so-logs-endpoint_x_events_x_api: *dataStreamSettings
so-logs-endpoint_x_events_x_file: *dataStreamSettings
so-logs-endpoint_x_events_x_library: *dataStreamSettings
so-logs-endpoint_x_events_x_network: *dataStreamSettings
so-logs-endpoint_x_events_x_process: *dataStreamSettings
so-logs-endpoint_x_events_x_registry: *dataStreamSettings
so-logs-endpoint_x_events_x_security: *dataStreamSettings
so-logs-elastic_agent_x_filebeat: *dataStreamSettings
so-logs-elastic_agent_x_fleet_server: *dataStreamSettings
so-logs-elastic_agent_x_heartbeat: *dataStreamSettings
so-logs-elastic_agent: *dataStreamSettings
so-logs-elastic_agent_x_metricbeat: *dataStreamSettings
so-logs-elastic_agent_x_osquerybeat: *dataStreamSettings
so-logs-elastic_agent_x_packetbeat: *dataStreamSettings
so-logs-elasticsearch_x_server: *dataStreamSettings
so-metrics-endpoint_x_metadata: *dataStreamSettings
so-metrics-endpoint_x_metrics: *dataStreamSettings
so-metrics-endpoint_x_policy: *dataStreamSettings
so-metrics-nginx_x_stubstatus: *dataStreamSettings
so-metrics-vsphere_x_datastore: *dataStreamSettings
so-metrics-vsphere_x_host: *dataStreamSettings
so-metrics-vsphere_x_virtualmachine: *dataStreamSettings
so-common: *dataStreamSettings
so-endgame: *dataStreamSettings
so-idh: *dataStreamSettings
so-suricata: *dataStreamSettings
so-suricata_x_alerts: *dataStreamSettings
so-import: *dataStreamSettings
so-kratos: *dataStreamSettings
so-hydra: *dataStreamSettings
so-kismet: *dataStreamSettings
so-logstash: *dataStreamSettings
so-redis: *dataStreamSettings
so-strelka: *dataStreamSettings
so-syslog: *dataStreamSettings
so-zeek: *dataStreamSettings
# Managed SOC integration annotations are inserted below this line. Referencing '*dataStreamSettings'
so-case: &indexSettings
index_sorting:
description: Sorts the index by event time, at the cost of additional processing resource consumption.
forcedType: bool
global: True
advanced: True
helpLink: elasticsearch
index_template:
index_patterns:
description: Patterns for matching multiple indices or tables.
forcedType: "[]string"
multiline: True
global: True
advanced: True
helpLink: elasticsearch
template:
settings:
index:
number_of_replicas:
description: Number of replicas required for this index. Multiple replicas protects against data loss, but also increases storage costs.
forcedType: int
global: True
advanced: True
helpLink: elasticsearch
auto_expand_replicas:
description: Automatically expand the number of replicas based on the number of data nodes in the cluster. This can help ensure high availability as the cluster scales up or down.
forcedType: string
regex: "^(0-[1-9]|1-[2-9]|2-[3-9]|3-[4-9]|4-[5-9]|5-[6-9]|6-[7-9]|7-[89]|8-9|[0-9]-all|false)$"
regexFailureMessage: Must be in the format of "x-y" where x is minimum number of replicas and y is maximum number of replicas, or "0-all" to specify a minimum of 0 and no maximum, or "false" to disable automatic replica expansion.
global: True
advanced: True
helpLink: elasticsearch
mapping:
total_fields:
limit:
description: Max number of fields that can exist on a single index. Larger values will consume more resources.
global: True
advanced: True
helpLink: elasticsearch
refresh_interval:
description: Seconds between index refreshes. Shorter intervals can cause query performance to suffer since this is a synchronous and resource-intensive operation.
global: True
advanced: True
helpLink: elasticsearch
number_of_shards:
description: Number of shards required for this index. Using multiple shards increases fault tolerance, but also increases storage and network costs.
global: True
advanced: True
helpLink: elasticsearch
sort:
field:
description: The field to sort by. Must set index_sorting to True.
global: True
advanced: True
helpLink: elasticsearch
order:
description: The order to sort by. Must set index_sorting to True.
global: True
advanced: True
helpLink: elasticsearch
mappings:
_meta:
package:
name:
description: Meta settings for the mapping.
global: True
advanced: True
helpLink: elasticsearch
managed_by:
description: Meta settings for the mapping.
global: True
advanced: True
helpLink: elasticsearch
managed:
description: Meta settings for the mapping.
forcedType: bool
global: True
advanced: True
helpLink: elasticsearch
composed_of:
description: The index template is composed of these component templates.
forcedType: "[]string"
global: True
advanced: True
helpLink: elasticsearch
priority:
description: The priority of the index template.
forcedType: int
global: True
advanced: True
helpLink: elasticsearch
policy:
phases:
hot:
min_age:
description: Minimum age of index. This determines when the index should be moved to the hot tier.
global: True
advanced: True
helpLink: elasticsearch
actions:
set_priority:
priority:
description: Priority of index. This is used for recovery after a node restart. Indices with higher priorities are recovered before indices with lower priorities.
forcedType: int
global: True
advanced: True
helpLink: elasticsearch
rollover:
max_age:
description: Maximum age of index. Once an index reaches this limit, it will be rolled over into a new index.
global: True
advanced: True
helpLink: elasticsearch
max_primary_shard_size:
description: Maximum primary shard size. Once an index reaches this limit, it will be rolled over into a new index.
global: True
advanced: True
helpLink: elasticsearch
shrink:
method:
description: Shrink the index to a new index with fewer primary shards. Shrink operation is by count or size.
options:
- COUNT
- SIZE
global: True
advanced: True
forcedType: string
number_of_shards:
title: shard count
description: Desired shard count. Note that this value is only used when the shrink method selected is 'COUNT'.
global: True
forcedType: int
advanced: True
max_primary_shard_size:
title: max shard size
description: Desired shard size in gb/tb/pb eg. 100gb. Note that this value is only used when the shrink method selected is 'SIZE'.
regex: ^[0-9]+(?:gb|tb|pb)$
global: True
forcedType: string
advanced: True
allow_write_after_shrink:
description: Allow writes after shrink.
global: True
forcedType: bool
default: False
advanced: True
forcemerge:
max_num_segments:
description: Reduce the number of segments in each index shard and clean up deleted documents.
global: True
forcedType: int
advanced: True
index_codec:
title: compression
description: Use higher compression for stored fields at the cost of slower performance.
forcedType: bool
global: True
default: False
advanced: True
warm:
min_age:
description: Minimum age of index. ex. 30d - This determines when the index should be moved to the warm tier. Nodes in the warm tier generally dont need to be as fast as those in the hot tier. Its important to note that this is calculated relative to the rollover date (NOT the original creation date of the index). For example, if you have an index that is set to rollover after 30 days and warm min_age set to 30 then there will be 30 days from index creation to rollover and then an additional 30 days before moving to warm tier.
regex: ^[0-9]{1,5}d$
forcedType: string
global: True
advanced: True
helpLink: elasticsearch
actions:
set_priority:
priority:
description: Priority of index. This is used for recovery after a node restart. Indices with higher priorities are recovered before indices with lower priorities.
forcedType: int
global: True
advanced: True
helpLink: elasticsearch
rollover:
max_age:
description: Maximum age of index. Once an index reaches this limit, it will be rolled over into a new index.
global: True
advanced: True
helpLink: elasticsearch
max_primary_shard_size:
description: Maximum primary shard size. Once an index reaches this limit, it will be rolled over into a new index.
global: True
advanced: True
helpLink: elasticsearch
shrink:
method:
description: Shrink the index to a new index with fewer primary shards. Shrink operation is by count or size.
options:
- COUNT
- SIZE
global: True
advanced: True
number_of_shards:
title: shard count
description: Desired shard count. Note that this value is only used when the shrink method selected is 'COUNT'.
global: True
forcedType: int
advanced: True
max_primary_shard_size:
title: max shard size
description: Desired shard size in gb/tb/pb eg. 100gb. Note that this value is only used when the shrink method selected is 'SIZE'.
regex: ^[0-9]+(?:gb|tb|pb)$
global: True
forcedType: string
advanced: True
allow_write_after_shrink:
description: Allow writes after shrink.
global: True
forcedType: bool
default: False
advanced: True
forcemerge:
max_num_segments:
description: Reduce the number of segments in each index shard and clean up deleted documents.
global: True
forcedType: int
advanced: True
index_codec:
title: compression
description: Use higher compression for stored fields at the cost of slower performance.
forcedType: bool
global: True
default: False
advanced: True
allocate:
number_of_replicas:
description: Set the number of replicas. Remains the same as the previous phase by default.
forcedType: int
global: True
advanced: True
cold:
min_age:
description: Minimum age of index. ex. 60d - This determines when the index should be moved to the cold tier. While still searchable, this tier is typically optimized for lower storage costs rather than search speed. Its important to note that this is calculated relative to the rollover date (NOT the original creation date of the index). For example, if you have an index that is set to rollover after 30 days and cold min_age set to 60 then there will be 30 days from index creation to rollover and then an additional 60 days before moving to cold tier.
regex: ^[0-9]{1,5}d$
forcedType: string
global: True
advanced: True
helpLink: elasticsearch
actions:
set_priority:
priority:
description: Used for index recovery after a node restart. Indices with higher priorities are recovered before indices with lower priorities.
forcedType: int
global: True
advanced: True
helpLink: elasticsearch
allocate:
number_of_replicas:
description: Set the number of replicas. Remains the same as the previous phase by default.
forcedType: int
global: True
advanced: True
delete:
min_age:
description: Minimum age of index. ex. 90d - This determines when the index should be deleted. Its important to note that this is calculated relative to the rollover date (NOT the original creation date of the index). For example, if you have an index that is set to rollover after 30 days and delete min_age set to 90 then there will be 30 days from index creation to rollover and then an additional 90 days before deletion.
regex: ^[0-9]{1,5}d$
forcedType: string
global: True
advanced: True
helpLink: elasticsearch
_meta:
package:
name:
description: Meta settings for the mapping.
global: True
advanced: True
helpLink: elasticsearch
managed_by:
description: Meta settings for the mapping.
global: True
advanced: True
helpLink: elasticsearch
managed:
description: Meta settings for the mapping.
forcedType: bool
global: True
advanced: True
helpLink: elasticsearch
sos-backup: *indexSettings
so-detection: *indexSettings
so-assistant-chat: *indexSettings
so-assistant-session: *indexSettings
so-metrics-fleet_server_x_agent_status: &fleetMetricsSettings
index_sorting:
description: Sorts the index by event time, at the cost of additional processing resource consumption.
+35 -4
View File
@@ -5,6 +5,7 @@
{% import_yaml 'elasticsearch/defaults.yaml' as ELASTICSEARCHDEFAULTS %}
{% set DEFAULT_GLOBAL_OVERRIDES = ELASTICSEARCHDEFAULTS.elasticsearch.index_settings.pop('global_overrides') %}
{% set DATA_RETENTION_METHOD = salt['pillar.get']('elasticsearch:data_retention_method', ELASTICSEARCHDEFAULTS.elasticsearch.get('data_retention_method', 'ILM')) %}
{% set PILLAR_GLOBAL_OVERRIDES = {} %}
{% set ES_INDEX_PILLAR = salt['pillar.get']('elasticsearch:index_settings', {}) %}
@@ -61,15 +62,25 @@
{% if ALL_ADDON_SETTINGS_ORIG.keys() | length > 0 %}
{% for index in ALL_ADDON_SETTINGS_ORIG.keys() %}
{% do ALL_ADDON_SETTINGS_GLOBAL_OVERRIDES.update({index: salt['defaults.merge'](ALL_ADDON_SETTINGS_ORIG[index], PILLAR_GLOBAL_OVERRIDES, in_place=False)}) %}
{# Explicitly excluding addon indices from ES_INDEX_SETTINGS_ORIG
When manager.soc_managed_annotations runs, new entries are added to the salt/elasticsearch/defaults.yaml file to support 'revert to default' functionality.
Subsequent map renders will then incorrectly include 'integration X' in 'ES_INDEX_SETTINGS_ORIG' due to being in the defaults.yaml file. #}
{% if index in ES_INDEX_SETTINGS_ORIG.keys() %}
{% do ES_INDEX_SETTINGS_ORIG.pop(index) %}
{% endif %}
{% endfor %}
{% endif %}
{% set ES_INDEX_SETTINGS = {} %}
{% macro create_final_index_template(DEFINED_SETTINGS, GLOBAL_OVERRIDES, FINAL_INDEX_SETTINGS) %}
{% macro create_final_index_template(DEFINED_SETTINGS, GLOBAL_OVERRIDES, FINAL_INDEX_SETTINGS, EXCLUDE_INDICES=[]) %}
{% do GLOBAL_OVERRIDES.update(salt['defaults.merge'](GLOBAL_OVERRIDES, ES_INDEX_PILLAR, in_place=False)) %}
{% for index, settings in GLOBAL_OVERRIDES.items() %}
{% if index in EXCLUDE_INDICES %}
{% continue %}
{% endif %}
{# prevent this action from being performed on custom defined indices. #}
{# the custom defined index is not present in either of the dictionaries and fails to reder. #}
{% if index in DEFINED_SETTINGS and index in GLOBAL_OVERRIDES %}
@@ -95,6 +106,17 @@
{% if not settings.get('index_sorting', False) | to_bool and settings.index_template.template.settings.index.sort is defined %}
{% do settings.index_template.template.settings.index.pop('sort') %}
{% endif %}
{% if DATA_RETENTION_METHOD == 'DLM' and settings.index_template.data_stream is defined and settings.data_stream_lifecycle is defined %}
{% if settings.data_stream_lifecycle.data_retention is defined and settings.data_stream_lifecycle.data_retention %}
{% do settings.index_template.template.update({'lifecycle': {'data_retention': settings.data_stream_lifecycle.data_retention}}) %}
{% else %}
{% do settings.index_template.template.update({'lifecycle': {}}) %}
{% endif %}
{% if settings.index_template.template.settings.index.lifecycle is not defined %}
{% do settings.index_template.template.settings.index.update({'lifecycle': {}}) %}
{% endif %}
{% do settings.index_template.template.settings.index.lifecycle.update({'prefer_ilm': false}) %}
{% endif %}
{% endif %}
{# advanced ilm actions #}
@@ -150,10 +172,19 @@
{% endfor %}
{% endmacro %}
{{ create_final_index_template(ES_INDEX_SETTINGS_ORIG, ES_INDEX_SETTINGS_GLOBAL_OVERRIDES, ES_INDEX_SETTINGS) }}
{{ create_final_index_template(ALL_ADDON_SETTINGS_ORIG, ALL_ADDON_SETTINGS_GLOBAL_OVERRIDES, ALL_ADDON_SETTINGS) }}
{# Exclude addon integrations from final ES_INDEX_SETTINGS #}
{{ create_final_index_template(ES_INDEX_SETTINGS_ORIG, ES_INDEX_SETTINGS_GLOBAL_OVERRIDES, ES_INDEX_SETTINGS, ALL_ADDON_SETTINGS_ORIG.keys() | list ) }}
{# Exclude SO managed indices, otherwise ALL_ADDON_SETTINGS will include pillar values
of core integrations without merging defaults, resulting in an overlapping, but bad index template being generated. #}
{{ create_final_index_template(ALL_ADDON_SETTINGS_ORIG, ALL_ADDON_SETTINGS_GLOBAL_OVERRIDES, ALL_ADDON_SETTINGS, ES_INDEX_SETTINGS_ORIG.keys() | list ) }}
{% set SO_MANAGED_INDICES = [] %}
{% for index, settings in ES_INDEX_SETTINGS.items() %}
{% do SO_MANAGED_INDICES.append(index) %}
{% endfor %}
{% endfor %}
{% set ADDON_INDICES = [] %}
{% for index, settings in ALL_ADDON_SETTINGS.items() %}
{% do ADDON_INDICES.append(index) %}
{% endfor %}
@@ -125,14 +125,6 @@ load_component_templates() {
done
}
check_elasticsearch_responsive() {
# Cannot load templates if Elasticsearch is not responding.
# NOTE: Slightly faster exit w/ failure than previous "retry 240 1" if there is a problem with Elasticsearch the
# script should exit sooner rather than hang at the 'so-elasticsearch-templates' salt state.
retry 3 15 "so-elasticsearch-query / --output /dev/null --fail" ||
fail "Elasticsearch is not responding. Please review Elasticsearch logs /opt/so/log/elasticsearch/securityonion.log for more details. Additionally, consider running so-elasticsearch-troubleshoot."
}
index_templates_exist() {
local templates_dir="$1"
@@ -0,0 +1,178 @@
#!/bin/bash
# Copyright Security Onion Solutions LLC and/or licensed to Security Onion Solutions LLC under one
# or more contributor license agreements. Licensed under the Elastic License 2.0 as shown at
# https://securityonion.net/license; you may not use this file except in compliance with the
# Elastic License 2.0.
. /usr/sbin/so-common
{%- import_yaml 'elasticsearch/defaults.yaml' as ELASTICSEARCHDEFAULTS %}
{%- set DATA_RETENTION_METHOD = salt['pillar.get']('elasticsearch:data_retention_method', ELASTICSEARCHDEFAULTS.elasticsearch.get('data_retention_method', 'ILM')) %}
ELASTICSEARCH_TEMPLATES_DIR="${ELASTICSEARCH_TEMPLATES_DIR:-/opt/so/conf/elasticsearch/templates}"
TEMPLATE_DIRS=(
"${ELASTICSEARCH_TEMPLATES_DIR}/index"
"${ELASTICSEARCH_TEMPLATES_DIR}/addon-index"
)
DATA_RETENTION_METHOD=$(cat <<'EOF'
{{ DATA_RETENTION_METHOD }}
EOF
)
DLM_FAILURES=0
DLM_FAILURE_NAMES=()
if [[ "$DATA_RETENTION_METHOD" != "DLM" && "$DATA_RETENTION_METHOD" != "ILM" ]]; then
echo "Unsupported data retention method $DATA_RETENTION_METHOD. Expected DLM or ILM."
exit 1
fi
validate_template_file() {
local template_file="$1"
if ! jq -e 'type == "object" and (.data_stream == null or (.data_stream | type == "object")) and (.template.lifecycle == null or (.template.lifecycle | type == "object")) and (.template.lifecycle.data_retention == null or (.template.lifecycle.data_retention | type == "string"))' >/dev/null 2>&1 "$template_file"; then
echo "Invalid index template JSON: $template_file"
return 1
fi
}
is_data_stream_template() {
jq -e '.data_stream | type == "object"' >/dev/null 2>&1 "$1"
}
has_data_stream_lifecycle() {
jq -e '.template.lifecycle | type == "object"' >/dev/null 2>&1 "$1"
}
get_data_retention() {
jq -r '.template.lifecycle.data_retention // ""' "$1"
}
find_template_file() {
local template="$1"
local template_dir
local template_file
for template_dir in "${TEMPLATE_DIRS[@]}"; do
template_file="${template_dir}/${template}-template.json"
if [[ -f "$template_file" ]]; then
echo "$template_file"
return 0
fi
done
return 1
}
set_data_stream_lifecycle() {
local data_stream="$1"
local data_retention="$2"
local body
local output
if [[ -n "$data_retention" ]]; then
if jq -e --arg data_stream "$data_stream" --arg data_retention "$data_retention" '.data_streams[]? | select(.name == $data_stream and .lifecycle.enabled == true and .lifecycle.data_retention == $data_retention)' >/dev/null 2>&1 <<< "$data_streams"; then
echo "DLM lifecycle already set for $data_stream with data_retention $data_retention, skipping."
return 0
fi
elif jq -e --arg data_stream "$data_stream" '.data_streams[]? | select(.name == $data_stream and .lifecycle.enabled == true and (.lifecycle.data_retention == null))' >/dev/null 2>&1 <<< "$data_streams"; then
echo "DLM lifecycle already set for $data_stream with indefinite retention, skipping."
return 0
fi
if [[ -n "$data_retention" ]]; then
body=$(jq -cn --arg data_retention "$data_retention" '{data_retention: $data_retention}')
else
# Setting indefinite retention
body='{}'
fi
if ! output=$(so-elasticsearch-query "_data_stream/${data_stream}/_lifecycle" -XPUT -d "$body" --retry 3 --retry-delay 5 --fail); then
echo "Failed to set data stream lifecycle for $data_stream."
echo "$output"
return 1
fi
if [[ -n "$data_retention" ]]; then
echo "Set DLM lifecycle for $data_stream with data_retention $data_retention."
else
echo "Set DLM lifecycle for $data_stream with indefinite retention."
fi
}
disable_data_stream_lifecycle() {
local data_stream="$1"
local body='{"enabled":false}'
local output
if ! jq -e --arg data_stream "$data_stream" '.data_streams[]? | select(.name == $data_stream and .lifecycle != null and .lifecycle.enabled != false)' >/dev/null 2>&1 <<< "$data_streams"; then
# No action needed
return 0
fi
if ! output=$(so-elasticsearch-query "_data_stream/${data_stream}/_lifecycle" -XPUT -d "$body" --retry 3 --retry-delay 5 --fail); then
echo "Failed to disable data stream lifecycle for $data_stream."
echo "$output"
return 1
fi
echo "Disabled DLM lifecycle for $data_stream."
}
process_data_stream() {
local data_stream="$1"
local data_retention="$2"
if [[ "$DATA_RETENTION_METHOD" == "DLM" ]]; then
set_data_stream_lifecycle "$data_stream" "$data_retention"
else
disable_data_stream_lifecycle "$data_stream"
fi
}
check_elasticsearch_responsive
if ! data_streams=$(so-elasticsearch-query "_data_stream?format=json" --retry 3 --retry-delay 5 --fail); then
echo "Failed to retrieve data streams."
exit 1
fi
while read -r data_stream_config; do
data_stream=$(jq -r '.name' <<< "$data_stream_config")
template=$(jq -r '.template' <<< "$data_stream_config")
if ! template_file=$(find_template_file "$template"); then
echo "Skipping $data_stream: index template file not found for $template."
continue
fi
validate_template_file "$template_file" || exit 1
if ! is_data_stream_template "$template_file"; then
echo "Skipping $data_stream: $template_file is not a data stream template."
continue
fi
if [[ "$DATA_RETENTION_METHOD" == "DLM" ]] && ! has_data_stream_lifecycle "$template_file"; then
echo "Skipping $data_stream: $template_file does not define data stream lifecycle."
continue
fi
data_retention=$(get_data_retention "$template_file")
if ! process_data_stream "$data_stream" "$data_retention"; then
DLM_FAILURES=$((DLM_FAILURES + 1))
DLM_FAILURE_NAMES+=("$data_stream")
fi
done < <(jq -c '.data_streams[]' <<< "$data_streams")
if [[ $DLM_FAILURES -eq 0 ]]; then
echo "Data stream lifecycle updates completed successfully."
else
echo "Encountered $DLM_FAILURES failure(s) updating data stream lifecycle:"
for failed_data_stream in "${DLM_FAILURE_NAMES[@]}"; do
echo " - $failed_data_stream"
done
exit 1
fi
+1 -1
View File
@@ -103,7 +103,7 @@ kratos:
config:
session:
lifespan:
description: Defines the length of a login session.
description: Defines the length of a login session before it will timeout, and require a new login.
global: True
helpLink: kratos
whoami:
+3 -2
View File
@@ -26,12 +26,12 @@ logstash:
manager:
- so/0011_input_endgame.conf
- so/0012_input_elastic_agent.conf.jinja
- so/0013_input_lumberjack_fleet.conf
- so/0013_input_lumberjack_fleet.conf.jinja
- so/9999_output_redis.conf.jinja
receiver:
- so/0011_input_endgame.conf
- so/0012_input_elastic_agent.conf.jinja
- so/0013_input_lumberjack_fleet.conf
- so/0013_input_lumberjack_fleet.conf.jinja
- so/9999_output_redis.conf.jinja
search:
- so/0900_input_redis.conf.jinja
@@ -69,4 +69,5 @@ logstash:
pipeline_x_batch_x_size: 125
pipeline_x_ecs_compatibility: disabled
dmz_nodes: []
latency_metrics: False
@@ -1,3 +1,4 @@
{%- from 'logstash/map.jinja' import LOGSTASH_MERGED %}
input {
elastic_agent {
port => 5055
@@ -11,10 +12,15 @@ input {
}
}
filter {
if ![metadata] {
mutate {
rename => {"@metadata" => "metadata"}
{% if LOGSTASH_MERGED.get('latency_metrics', False) %}
ruby {
code => "event.set('[_tmp][logstash_from_agent]', Time.now().utc.iso8601(3));"
}
{% endif %}
if ![metadata] {
mutate {
rename => {"@metadata" => "metadata"}
}
}
}
}
@@ -1,23 +0,0 @@
input {
elastic_agent {
port => 5056
tags => [ "elastic-agent", "fleet-lumberjack-input" ]
ssl_enabled => true
ssl_certificate => "/usr/share/logstash/elasticfleet-lumberjack.crt"
ssl_key => "/usr/share/logstash/elasticfleet-lumberjack.key"
ecs_compatibility => v8
id => "fleet-lumberjack-in"
codec => "json"
}
}
filter {
if ![metadata] {
mutate {
rename => {"@metadata" => "metadata"}
}
}
}
@@ -0,0 +1,26 @@
{%- from 'logstash/map.jinja' import LOGSTASH_MERGED %}
input {
elastic_agent {
port => 5056
tags => [ "elastic-agent", "fleet-lumberjack-input" ]
ssl_enabled => true
ssl_certificate => "/usr/share/logstash/elasticfleet-lumberjack.crt"
ssl_key => "/usr/share/logstash/elasticfleet-lumberjack.key"
ecs_compatibility => v8
id => "fleet-lumberjack-in"
codec => "json"
}
}
filter {
{% if LOGSTASH_MERGED.get('latency_metrics', False) %}
ruby {
code => "event.set('[_tmp][logstash_from_fleet]', Time.now().utc.iso8601(3));"
}
{% endif %}
if ![metadata] {
mutate {
rename => {"@metadata" => "metadata"}
}
}
}
@@ -1,3 +1,4 @@
{%- from 'logstash/map.jinja' import LOGSTASH_MERGED %}
{%- set kafka_password = salt['pillar.get']('kafka:config:password') %}
{%- set kafka_trustpass = salt['pillar.get']('kafka:config:trustpass') %}
{%- set kafka_brokers = salt['pillar.get']('kafka:nodes', {}) %}
@@ -30,6 +31,11 @@ input {
}
}
filter {
{% if LOGSTASH_MERGED.get('latency_metrics', False) %}
ruby {
code => "event.set('[_tmp][logstash_from_kafka]', Time.now().utc.iso8601(3));"
}
{% endif %}
if ![metadata] {
mutate {
rename => { "@metadata" => "metadata" }
@@ -1,4 +1,4 @@
{%- from 'logstash/map.jinja' import LOGSTASH_REDIS_NODES with context %}
{%- from 'logstash/map.jinja' import LOGSTASH_REDIS_NODES, LOGSTASH_MERGED %}
{%- set REDIS_PASS = salt['pillar.get']('redis:config:requirepass') %}
{%- for index in range(LOGSTASH_REDIS_NODES|length) %}
@@ -18,3 +18,10 @@ input {
}
{% endfor %}
{% endfor -%}
filter {
{% if LOGSTASH_MERGED.get('latency_metrics', False) %}
ruby {
code => "event.set('[_tmp][logstash_from_redis]', Time.now().utc.iso8601(3));"
}
{% endif %}
}
@@ -1,3 +1,11 @@
{%- from 'logstash/map.jinja' import LOGSTASH_MERGED %}
{% if LOGSTASH_MERGED.get('latency_metrics', False) %}
filter {
ruby {
code => "event.set('[_tmp][logstash_to_elasticsearch]', Time.now().utc.iso8601(3));"
}
}
{% endif %}
output {
if "elastic-agent" in [tags] and "so-ip-mappings" in [tags] {
elasticsearch {
@@ -13,13 +13,20 @@ filter {
add_tag => "fleet-lumberjack-{{ GLOBALS.hostname }}"
}
}
output {
lumberjack {
codec => json
{%- from 'logstash/map.jinja' import LOGSTASH_MERGED %}
{% if LOGSTASH_MERGED.get('latency_metrics', False) %}
filter {
ruby {
code => "event.set('[_tmp][fleet_to_logstash]', Time.now().utc.iso8601(3));"
}
}
{% endif %}
output {
lumberjack {
codec => json
hosts => {{ FAILOVER_LOGSTASH_NODES }}
ssl_certificate => "/usr/share/filebeat/ca.crt"
port => 5056
port => 5056
id => "fleet-lumberjack-{{ GLOBALS.hostname }}"
}
}
}
@@ -1,10 +1,17 @@
{%- from 'logstash/map.jinja' import LOGSTASH_MERGED %}
{%- if grains.role in ['so-heavynode', 'so-receiver'] %}
{%- set HOST = GLOBALS.hostname %}
{%- else %}
{%- set HOST = GLOBALS.manager %}
{%- endif %}
{%- set REDIS_PASS = salt['pillar.get']('redis:config:requirepass') %}
{% if LOGSTASH_MERGED.get('latency_metrics', False) %}
filter {
ruby {
code => "event.set('[_tmp][logstash_to_redis]', Time.now().utc.iso8601(3));"
}
}
{% endif %}
output {
redis {
host => '{{ HOST }}'
+5
View File
@@ -86,3 +86,8 @@ logstash:
multiline: True
advanced: True
forcedType: "[]string"
latency_metrics:
description: Enable latency metrics within events processed by logstash. Useful for pinpointing log ingest delay.
forcedType: bool
global: False
advanced: True
+27 -32
View File
@@ -16,40 +16,35 @@
{% endif %}
{% endfor %}
{% endfor %}
{% set soc_annotation_lines = [] %}
{% set defaults_lines = [] %}
{% for k in matched_integration_names %}
{% do soc_annotation_lines.append(' ' ~ k ~ ': *dataStreamSettings') %}
{% do defaults_lines.append(' ' ~ k ~ ':') %}
{% set defaults_yaml = salt['slsutil.serialize']('yaml', ADDON_INTEGRATION_DEFAULTS[k], default_flow_style=False).strip() %}
{% for line in defaults_yaml.splitlines() %}
{% do defaults_lines.append(' ' ~ line) %}
{% endfor %}
{% endfor %}
{% set es_soc_annotations = '/opt/so/saltstack/default/salt/elasticsearch/soc_elasticsearch.yaml' %}
{{ es_soc_annotations }}:
file.serialize:
- dataset:
{% set data = salt['file.read'](es_soc_annotations) | load_yaml %}
{% set es = data.get('elasticsearch', {}) %}
{% set index_settings = es.get('index_settings', {}) %}
{% set input = index_settings.get('so-logs', {}) %}
{% for k in matched_integration_names %}
{% do index_settings.update({k: input}) %}
{% endfor %}
{% for k in addon_integration_keys %}
{% if k not in matched_integration_names and k in index_settings %}
{% do index_settings.pop(k) %}
{% endif %}
{% endfor %}
{{ data }}
manage_soc_annotations:
file.blockreplace:
- name: {{ es_soc_annotations }}
- marker_start: ' # START managed SOC integration annotations'
- marker_end: ' # END managed SOC integration annotations'
- content: {{ soc_annotation_lines | join('\n') | tojson }}
- insert_after_match: '^ # Managed SOC integration annotations are inserted below this line\.'
- append_if_not_found: False
- show_changes: True
{# Managed elasticsearch/defaults.yaml file for enabling 'Revert to default' via SOC UI for newly added config items #}
{% set es_defaults = '/opt/so/saltstack/default/salt/elasticsearch/defaults.yaml' %}
{{ es_defaults }}:
file.serialize:
- dataset:
{% set data = salt['file.read'](es_defaults) | load_yaml %}
{% set es = data.get('elasticsearch', {}) %}
{% set index_settings = es.get('index_settings', {}) %}
{% for k in matched_integration_names %}
{% set input = ADDON_INTEGRATION_DEFAULTS[k] %}
{% do index_settings.update({k: input})%}
{% endfor %}
{% for k in addon_integration_keys %}
{% if k not in matched_integration_names and k in index_settings %}
{% do index_settings.pop(k) %}
{% endif %}
{% endfor %}
{{ data }}
{% endif %}
file.blockreplace:
- marker_start: ' # START managed SOC integration defaults'
- marker_end: ' # END managed SOC integration defaults'
- content: {{ defaults_lines | join('\n') | tojson }}
- insert_after_match: '^ index_settings:$'
- append_if_not_found: False
- show_changes: True
{% endif %}
+5 -3
View File
@@ -31,11 +31,13 @@ sync_es_users:
- http: wait_for_kratos
- file: so-user.lock # require so-user.lock file to be missing
# we dont want this added too early in setup, so we add the onlyif to verify 'startup_states: highstate'
# is in the minion config. That line is added before the final highstate during setup
# we dont want this added too early in setup, so the onlyif gates on the
# /opt/so/state/setup-complete marker. The marker is written by
# mark_setup_complete in setup/so-functions just before the final setup
# highstate (and by an upgrade-path state for systems set up under the old gate).
so-user_sync:
cron.present:
- user: root
- name: 'PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin:/root/bin /usr/sbin/so-user sync &>> /opt/so/log/soc/sync.log'
- identifier: so-user_sync
- onlyif: "grep -x 'startup_states: highstate' /etc/salt/minion"
- onlyif: "test -e /opt/so/state/setup-complete"
+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()
+352 -26
View File
@@ -188,13 +188,6 @@ airgap_update_dockers() {
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() {
docker stop so-dockerregistry
docker rm so-dockerregistry
@@ -370,8 +363,9 @@ preupgrade_changes() {
# This function is to add any new pillar items if needed.
echo "Checking to see if changes are needed."
[[ "$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.1.0" ]] && up_to_3.2.0
true
}
@@ -381,6 +375,7 @@ postupgrade_changes() {
[[ "$POSTVERSION" =~ ^2\.4\.21[0-9]+$ ]] && post_to_3.0.0
[[ "$POSTVERSION" == "3.0.0" ]] && post_to_3.1.0
[[ "$POSTVERSION" == "3.1.0" ]] && post_to_3.2.0
true
}
@@ -533,6 +528,23 @@ elasticfleet_set_agent_logging_level_warn() {
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
@@ -556,14 +568,23 @@ check_transform_health_and_reauthorize() {
# - 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 \
--argjson t "$transforms_doc" \
--argjson s "$stats_doc" \
--argjson i "$installed_doc" '
($i.items | map({key: .name, value: .version}) | from_entries) as $pkg_ver
| ($s.transforms | map({key: .id, value: .health.status}) | from_entries) as $health
| [ $t.transforms[]
--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])}
@@ -604,6 +625,8 @@ check_transform_health_and_reauthorize() {
(( 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
@@ -644,6 +667,31 @@ ensure_postgres_secret() {
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() {
ensure_postgres_local_pillar
ensure_postgres_secret
@@ -651,7 +699,8 @@ up_to_3.1.0() {
elasticsearch_backup_index_templates
# Clear existing component template state file.
rm -f /opt/so/state/esfleet_component_templates.json
rename_strelka_scan_lnk
fix_logstash_0013_lumberjack_pipeline_name
INSTALLEDVERSION=3.1.0
}
@@ -688,6 +737,98 @@ post_to_3.1.0() {
### 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."
}
# Existing grids should keep ILM unless an admin explicitly opts in to DLM.
pin_elasticsearch_data_retention_method() {
local elasticsearch_file=/opt/so/saltstack/local/pillar/elasticsearch/soc_elasticsearch.sls
mkdir -p "$(dirname "$elasticsearch_file")"
[[ -f "$elasticsearch_file" ]] || touch "$elasticsearch_file"
if so-yaml.py get -r "$elasticsearch_file" elasticsearch.data_retention_method >/dev/null 2>&1; then
echo "elasticsearch.data_retention_method already set; leaving as-is."
return 0
fi
echo "Pinning existing grid to ILM data retention."
so-yaml.py add "$elasticsearch_file" elasticsearch.data_retention_method ILM
chown socore:socore "$elasticsearch_file"
}
# Addes auto_expand_replicas setting to .kibana_streams index template
#
# In Kibana 9.3.3 the auto_expand_replicas setting was not added to the .kibana_streams index template. Causing single node deployments to be stuck in yellow state (unable to assign replica). Here we update the template in place using the so_kibana system user (system managed index template) to include the auto_expand_replicas setting
#
# Reference: https://github.com/elastic/kibana/issues/263048
kibana_backport_streams_index_template() {
local current_template updated_template
current_template=$(so-elasticsearch-query "_index_template/.kibana_streams" --retry 3 --retry-delay 5 --fail)
if [[ -z "$current_template" ]]; then
echo "Unable to retrieve current .kibana_streams index template, skipping backport."
return 0
fi
updated_template=$(jq '.index_templates[0].index_template | .template.settings += {"index.auto_expand_replicas": "0-1"} | del(.created_date_millis, .modified_date_millis)' <<< "$current_template")
if ! kibana_user_pass=$(/usr/sbin/so-yaml.py get -r /opt/so/saltstack/local/pillar/elasticsearch/auth.sls elasticsearch.auth.users.so_kibana_user.pass); then
echo "Unable to retrieve so_kibana_user password, skipping .kibana_streams index template backport."
return 0
fi
if ! so-elasticsearch-query "_index_template/.kibana_streams" -XPUT -d "$updated_template" -u "so_kibana:$kibana_user_pass" --retry 3 --retry-delay 5 --fail; then
echo "Unable to automatically update .kibana_streams index template"
return 0
fi
## NOTE: Should really add a check here for existing .kibana_streams index and then update its config in place
}
up_to_3.2.0() {
fix_logstash_0013_lumberjack_pipeline_name
pin_elasticsearch_data_retention_method
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
kibana_backport_streams_index_template
POSTVERSION=3.2.0
}
### 3.2.0 End ###
repo_sync() {
echo "Sync the local repo."
@@ -939,6 +1080,9 @@ verify_es_version_compatibility() {
local is_active_intermediate_upgrade=1
# supported upgrade paths for SO-ES versions
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"
)
@@ -962,6 +1106,171 @@ verify_es_version_compatibility() {
exit 160
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
[[ -f $statefile ]] || continue
@@ -980,10 +1289,6 @@ verify_es_version_compatibility() {
continue
fi
if [[ ! -f "$es_verification_script" ]]; then
create_intermediate_upgrade_verification_script "$es_verification_script"
fi
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."
if ! timeout --foreground 4000 bash "$es_verification_script" "$es_required_version_statefile_value" "$statefile"; then
@@ -1005,6 +1310,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 "
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
return 0
else
@@ -1290,7 +1615,7 @@ EOF
# Keeping this block in case we need to do a hotfix that requires salt update
apply_hotfix() {
echo "No actions required. ($INSTALLEDVERSION/$HOTFIXVERSION)"
echo "No actions required. ($INSTALLEDVERSION/$HOTFIXVERSION)"
}
failed_soup_restore_items() {
@@ -1362,13 +1687,13 @@ main() {
echo "Verifying we have the latest soup 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."
upgrade_check
upgrade_space
echo "Verifying Elasticsearch version compatibility across the grid before upgrading."
verify_es_version_compatibility
echo "Checking for Salt Master and Minion updates."
upgrade_check_salt
set -e
@@ -1388,7 +1713,8 @@ main() {
echo "Applying $HOTFIXVERSION hotfix"
# 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
backup_old_states_pillars
echo "Running so-config-backup script."
/sbin/so-config-backup
fi
copy_new_files
create_local_directories "/opt/so/saltstack/default"
@@ -1444,8 +1770,8 @@ main() {
# 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
echo ""
echo "Creating snapshots of default and local Salt states and pillars and saving to /nsm/backup/"
backup_old_states_pillars
echo "Running so-config-backup script."
/sbin/so-config-backup
fi
echo ""
+2
View File
@@ -225,6 +225,7 @@ http {
limit_req zone=auth_throttle burst={{ NGINXMERGED.config.throttle_login_burst }} nodelay;
limit_req_status 429;
proxy_pass http://{{ GLOBALS.manager }}:4433;
proxy_set_header Connection "Close";
proxy_read_timeout 90;
proxy_connect_timeout 90;
proxy_set_header Host $host;
@@ -237,6 +238,7 @@ http {
location ~ ^/auth/.*?(whoami|logout|settings|errors|webauthn.js) {
rewrite /auth/(.*) /$1 break;
proxy_pass http://{{ GLOBALS.manager }}:4433;
proxy_set_header Connection "Close";
proxy_read_timeout 90;
proxy_connect_timeout 90;
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
# 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:
salt.function:
@@ -20,3 +27,5 @@ update_salt_cloud_profile:
- sls:
- salt.cloud.config
- concurrent: True
{% endif %}
+10 -1
View File
@@ -12,7 +12,14 @@
{% if 'vrt' in salt['pillar.get']('features', []) %}
{% 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:
module.run:
@@ -24,6 +31,8 @@ delete_{{ vm_name }}_pillar:
- file.remove:
- path: /opt/so/saltstack/local/pillar/minions/{{ vm_name }}.sls
{% endif %}
{% else %}
{% do salt.log.error(
+3 -3
View File
@@ -46,10 +46,10 @@ postgresinitdir:
- require:
- file: postgresconfdir
postgresinitusers:
postgresinitdb:
file.managed:
- name: /opt/so/conf/postgres/init/init-users.sh
- source: salt://postgres/files/init-users.sh
- name: /opt/so/conf/postgres/init/init-db.sh
- source: salt://postgres/files/init-db.sh
- user: 939
- group: 939
- mode: 755
+4 -4
View File
@@ -31,7 +31,7 @@ so-postgres:
- POSTGRES_DB=securityonion
# Passwords are delivered via mounted 0600 secret files, not plaintext env vars.
# 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
- SO_POSTGRES_USER={{ SO_POSTGRES_USER }}
- 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/pg_hba.conf:/conf/pg_hba.conf: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.key:/conf/postgres.key:ro
- /etc/pki/tls/certs/intca.crt:/conf/ca.crt:ro
@@ -70,7 +70,7 @@ so-postgres:
- watch:
- file: postgresconf
- file: postgreshba
- file: postgresinitusers
- file: postgresinitdb
- file: postgres_super_secret
- file: postgres_app_secret
- x509: postgres_crt
@@ -78,7 +78,7 @@ so-postgres:
- require:
- file: postgresconf
- file: postgreshba
- file: postgresinitusers
- file: postgresinitdb
- file: postgres_super_secret
- file: postgres_app_secret
- x509: postgres_crt
@@ -17,6 +17,7 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-E
END IF;
END
\$\$;
GRANT ALL ON SCHEMA public 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
-- by default, which would let per-minion telegraf roles open sessions
@@ -31,4 +32,4 @@ EOSQL
# only ensures the shared database exists on first initialization.
if ! psql -U "$POSTGRES_USER" -tAc "SELECT 1 FROM pg_database WHERE datname='so_telegraf'" | grep -q 1; then
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -c "CREATE DATABASE so_telegraf"
fi
fi
+18 -85
View File
@@ -18,38 +18,22 @@ include:
{% set TG_OUT = TELEGRAFMERGED.output | upper %}
{% 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:
cmd.run:
- name: |
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
- name: /usr/sbin/so-postgres-wait
- require:
- 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
# would otherwise never get so_telegraf.
postgres_create_telegraf_db:
cmd.run:
- name: |
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
- name: /usr/sbin/so-telegraf-postgres create_db
- require:
- cmd: postgres_wait_ready
- file: postgres_sbin
# 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
@@ -57,68 +41,26 @@ postgres_create_telegraf_db:
# on first write are owned by the group role and every member can INSERT/SELECT.
postgres_telegraf_group_role:
cmd.run:
- name: |
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
- name: /usr/sbin/so-telegraf-postgres group_role
- require:
- cmd: postgres_create_telegraf_db
- file: postgres_sbin
{% set creds = salt['pillar.get']('telegraf:postgres_creds', {}) %}
{% for mid, entry in creds.items() %}
{% if entry.get('user') and entry.get('pass') %}
{% set u = entry.user %}
{% set p = entry.pass | replace("'", "''") %}
{% set p = entry.pass %}
postgres_telegraf_role_{{ u }}:
cmd.run:
- name: |
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 = '{{ u }}') THEN
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
- name: /usr/sbin/so-telegraf-postgres user
- env:
- ROLE_USER: {{ u | tojson }}
- ROLE_PASS: {{ p | tojson }}
- hide_output: True
- require:
- file: postgres_sbin
- cmd: postgres_telegraf_group_role
{% endif %}
@@ -130,21 +72,12 @@ postgres_telegraf_role_{{ u }}:
{% set retention = salt['pillar.get']('postgres:telegraf:retention_days', 14) | int %}
postgres_telegraf_retention_reconcile:
cmd.run:
- name: |
docker exec -i so-postgres psql -v ON_ERROR_STOP=1 -U postgres -d so_telegraf <<'EOSQL'
DO $$
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
- name: /usr/sbin/so-telegraf-postgres retention
- env:
- RETENTION_DAYS: {{ retention }}
- require:
- cmd: postgres_telegraf_group_role
- file: postgres_sbin
{% endif %}
+41 -7
View File
@@ -7,15 +7,29 @@
. /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.
umask 0077
TODAY=$(date '+%Y_%m_%d')
BACKUPDIR=/nsm/backup
BACKUPFILE="$BACKUPDIR/so-postgres-backup-$TODAY.sql.gz"
TMPFILE="$BACKUPFILE.tmp"
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
if [ -f "$BACKUPFILE" ]; then
@@ -27,13 +41,33 @@ if ! docker ps --format '{{.Names}}' | grep -q '^so-postgres$'; then
exit 0
fi
# Dump all databases and roles, compress
docker exec so-postgres pg_dumpall -U postgres | gzip > "$BACKUPFILE"
# Always clean up the temp file on exit; the success path clears this trap
# after the atomic rename so the finished backup is not deleted.
trap 'rm -f "$TMPFILE"' EXIT
# Retention cleanup
NUMBACKUPS=$(find $BACKUPDIR -type f -name "so-postgres-backup*" | wc -l)
# Dump all databases and roles, compress. Write to a temp file so the final
# 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
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"
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
+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
# 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' %}
check_and_trigger:
runner.setup_hypervisor.setup_environment:
- minion_id: {{ data['id'] }}
- minion_id: {{ hid }}
{% endif %}
{% if data['act'] == 'delete' %}
@@ -17,8 +20,7 @@ delete_hypervisor:
- args:
- mods: orch.delete_hypervisor
- pillar:
minion_id: {{ data['id'] }}
minion_id: {{ hid }}
{% endif %}
{% endif %}
+27 -15
View File
@@ -1,7 +1,7 @@
#!py
# 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
# Elastic License 2.0.
@@ -9,30 +9,42 @@ import logging
import os
import pwd
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():
vm_name = data['kwargs']['name']
logging.error("createEmptyPillar reactor: vm_name: %s" % vm_name)
pillar_root = '/opt/so/saltstack/local/pillar/minions/'
vm_name = data.get('kwargs', {}).get('name', '')
if not _VMNAME_RE.match(str(vm_name)):
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']
try:
# Get socore user and group IDs
socore_uid = pwd.getpwnam('socore').pw_uid
socore_gid = grp.getgrnam('socore').gr_gid
pillar_root_real = os.path.realpath(PILLAR_ROOT)
for f in pillar_files:
full_path = pillar_root + f
if not os.path.exists(full_path):
# Create empty file
os.mknod(full_path)
# Set ownership to socore:socore
os.chown(full_path, socore_uid, socore_gid)
# Set mode to 644 (rw-r--r--)
os.chmod(full_path, 0o640)
logging.error("createEmptyPillar reactor: created %s with socore:socore ownership and mode 644" % f)
full_path = os.path.join(PILLAR_ROOT, f)
resolved = os.path.realpath(full_path)
if os.path.dirname(resolved) != pillar_root_real:
log.error("createEmptyPillar reactor: refusing path outside pillar root: %s", resolved)
continue
if os.path.exists(resolved):
continue
os.mknod(resolved)
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:
logging.error("createEmptyPillar reactor: Error setting ownership/permissions: %s" % str(e))
log.error("createEmptyPillar reactor: Error setting ownership/permissions: %s", e)
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
# 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.
remove_key:
wheel.key.delete:
- args:
- match: {{ data['name'] }}
import logging
import re
{{ data['name'] }}_pillar_clean:
runner.state.orchestrate:
- args:
- mods: orch.vm_pillar_clean
- pillar:
vm_name: {{ data['name'] }}
log = logging.getLogger(__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}},
]},
],
},
}
+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
- salt.mine_functions
- salt.minion.service_file
- salt.minion.boot_highstate
{% if GLOBALS.is_manager %}
- ca.signing_policy
{% endif %}
@@ -80,11 +81,33 @@ set_log_levels:
- "log_level: info"
- "log_level_logfile: info"
enable_startup_states:
file.uncomment:
# startup_states: highstate caused a full highstate to run on every
# 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
- regex: '^startup_states: highstate$'
- unless: pgrep so-setup
- match: 'startup_states: highstate'
- 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 %}
@@ -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
-5
View File
@@ -8,11 +8,6 @@ set_role_grain:
- name: role
- value: so-{{ grains.id.split("_") | last }}
set_highstate:
file.append:
- name: /etc/salt/minion
- text: 'startup_states: highstate'
enable_salt_minion:
service.enabled:
- name: salt-minion
+10
View File
@@ -1519,6 +1519,16 @@ soc:
serviceAccountJSON: ""
serviceAccountLocation: ""
healthTimeoutSeconds: 5
onionconfig:
saltstackDir: /opt/so/saltstack
bypassEnabled: false
postgres:
host: ""
port: 5432
sslMode: "allow"
database: securityonion
user: ""
password: ""
salt:
queueDir: /opt/sensoroni/queue
timeoutMs: 45000
+137 -1
View File
@@ -117,6 +117,121 @@ transformations:
- type: logsource
product: linux
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
- id: convert_event_code_to_string
type: convert_type
@@ -126,15 +241,36 @@ transformations:
fields:
- event.code
# 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
type: add_condition
conditions:
event.category: 'process'
event.type: 'start'
host.os.type: 'windows'
rule_conditions:
- type: logsource
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
# This is an OS-agnostic mapping, to account for logs that don't specify source OS
- 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({'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 != 'soc' %}
{% 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).
global: 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:
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.
@@ -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.
global: True
advanced: True
multiline: True
forcedType: "[]{}"
exportNodeId:
description: The node ID on which export jobs will be executed.
+1 -1
View File
@@ -261,7 +261,7 @@ strelka:
priority: 5
options:
limit: 1000
'ScanLNK':
'ScanLnk':
- positive:
flavors:
- 'lnk_file'
+1 -1
View File
@@ -99,7 +99,7 @@ strelka:
'ScanJpeg': *scannerOptions
'ScanJson': *scannerOptions
'ScanLibarchive': *scannerOptions
'ScanLNK': *scannerOptions
'ScanLnk': *scannerOptions
'ScanLsb': *scannerOptions
'ScanLzma': *scannerOptions
'ScanMacho': *scannerOptions
+1 -1
View File
@@ -1,6 +1,6 @@
telegraf:
enabled: False
output: BOTH
output: INFLUXDB
config:
interval: '30s'
metric_batch_size: 1000
+27 -2
View File
@@ -119,7 +119,7 @@ base:
- kafka
- 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
- salt.master
- registry
@@ -146,6 +146,32 @@ base:
- stig
- 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}}':
- match: compound
- manager.hypervisor
@@ -286,7 +312,6 @@ base:
- libvirt
- libvirt.images
- elasticfleet.install_agent_grid
- stig
'*_desktop and G@saltversion:{{saltversion}}':
- sensoroni
+88 -8
View File
@@ -539,16 +539,19 @@ configure_minion() {
" x509_v2: true"\
"log_level: info"\
"log_level_logfile: info"\
"log_file: /opt/so/log/salt/minion"\
"#startup_states: highstate" >> "$minion_config"
"log_file: /opt/so/log/salt/minion" >> "$minion_config"
}
checkin_at_boot() {
local minion_config=/etc/salt/minion
mark_setup_complete() {
# 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"
sed -i 's/#startup_states: highstate/startup_states: highstate/' "$minion_config"
info "Marking setup as complete"
mkdir -p "$(dirname "$marker")"
touch "$marker"
}
check_requirements() {
@@ -745,6 +748,56 @@ configure_network_sensor() {
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() {
info "Setting up hypervisor bridge"
info "Checking $MNIC ipv4.method is auto or manual"
@@ -999,6 +1052,11 @@ filter_unused_nics() {
grep_string="$grep_string\|$BONDNIC"
done
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)
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 +1446,7 @@ network_init() {
title "Initializing Network"
disable_ipv6
set_hostname
if [[ ( $is_iso || $is_desktop_iso ) ]]; then
if [[ $is_iso || $is_desktop_iso ]]; then
set_management_interface
fi
}
@@ -1701,6 +1759,24 @@ remove_package() {
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
# CAUTION! SALT VERSION UDDATES - READ BELOW
# When updating the salt version, also update the version in:
@@ -2084,8 +2160,12 @@ set_initial_firewall_access() {
# Set up the management interface on the ISO
set_management_interface() {
title "Setting up the main interface"
if [[ $MNIC == "bond1" ]]; then
configure_management_bond || fail_setup
fi
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 -p connection show $MNIC"
else
+4 -1
View File
@@ -66,6 +66,9 @@ set_timezone
# Let's see what OS we are dealing with here
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".
is_desktop=
@@ -789,7 +792,7 @@ if ! [[ -f $install_opt_file ]]; then
error "Failed to run so-elastic-fleet-setup"
fail_setup
fi
checkin_at_boot
mark_setup_complete
set_initial_firewall_access
initialize_elasticsearch_indices "so-case so-casehistory so-assistant-session so-assistant-chat"
# run a final highstate before enabling scheduled highstates.
+83 -2
View File
@@ -845,18 +845,99 @@ whiptail_management_nic() {
[ -n "$TESTING" ] && return
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=$?
whiptail_check_exitstatus $exitstatus
while [ -z "$MNIC" ]
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=$?
whiptail_check_exitstatus $exitstatus
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() {
Binary file not shown.
Binary file not shown.