Repository navigation
Expand file tree
/
Copy pathcompose.yaml
More file actions
839 lines (825 loc) · 43 KB
/
Copy pathcompose.yaml
File metadata and controls
839 lines (825 loc) · 43 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
---
# The SOC stack: Wazuh and Velociraptor — the security half of ADR-0007's
# defended estate, and the thing that turns ADR-0029's domain from six VMs into
# something that reports what happened to it.
#
# Deployed on: odin (10.0.30.60, VLAN 30 / ImaginationLAN) — a SECOND guest on
# `Saruman`, beside `alexander`. Built 2026-09-27 by
# docs/runbooks/build-the-soc-guest.md; authored ahead of the
# guest, the way stacks/lab was ahead of alexander (#264 landed a
# day before #262) and stacks/sensitive ahead of trinity (#415).
# Decided by: docs/adr/0030-give-the-security-tooling-its-own-guest-and-its-own-stack.md
# — why a second guest and not alexander (Wazuh's stated minimums
# are six gigabytes before the dashboard, on an 8 GiB guest already
# running four services with no mem_limit), why one stack and not
# two, why the directory is not called `wazuh`, and which of the
# ports below are published and which are not.
# Deploy with: make up STACK=soc (from the repository root)
#
# Two stores, and nothing queries across them (ADR-0030). The indexer here holds
# the estate's security events; the lab's Loki on alexander holds the lab's own
# infrastructure logs. Wazuh's alerts.json is deliberately NOT tailed into Loki.
# The one crossing runs the other way: alloy/opensearch.alloy reads the
# indexer's health into the lab's Prometheus, which is the thing that says the
# SIEM stopped ingesting — the #62/#63 shape this repository keeps paying for.
# Nothing here reaches 10.0.99.20 (ADR-0007).
#
# ---------------------------------------------------------------------------
# On the comments in this file
# ---------------------------------------------------------------------------
# The settings shared with `stacks/observability/compose.yaml` — why `init:
# true`, why `pids_limit`, the healthcheck shape, the `${VAR:?}` guards that
# double as the SOPS required-key list — are argued at length there and are
# NOT repeated here; two copies of an argument drift the same way two copies
# of a config do. What IS written out here is every place this stack differs
# from that one, because those are the only lines a reader cannot get from
# the sibling file. Each is marked DIFFERENCE.
#
# The Wazuh half is derived from wazuh-docker's single-node compose file — the
# vendor's own reference for exactly this shape — and every departure from it
# is marked too, because "the vendor does it this way" is the argument a reader
# will otherwise assume.
name: soc
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
x-service-defaults: &service-defaults
restart: unless-stopped
logging: *default-logging
networks: [soc]
init: true
pids_limit: 512
# DIFFERENCE 1: memory limits from day one, where stacks/lab set none. The lab's
# argument — copying the estate's numbers would be a limit derived from a
# machine this one is not — does not apply to a service whose VENDOR states a
# minimum. Wazuh's are 4 GiB for the indexer and 2 GiB for the manager, and
# ADR-0030 rejected co-locating on alexander precisely because "nothing bounds
# the collision" there. These are bounds, not measurements: re-derive from
# container_memory_rss in the lab's Prometheus after a fortnight, the way #114
# did for the estate. No swap, for the reason the estate's file gives.
services:
# ---------------------------------------------------------------------------
# Wazuh indexer — OpenSearch, the store for the estate's security events.
#
# Service names carry a dot (`wazuh.indexer`, not `wazuh-indexer`), and that
# is forced rather than chosen: wazuh-certs-generator's validator rejects a
# bare hostname as "Invalid IP or DNS", and filebeat in the manager verifies
# the indexer's certificate in `full` mode, so the SAN and the name the
# manager dials have to be the same string. Upstream's names are kept so the
# certificates upstream's tool will issue match the names compose resolves.
# ---------------------------------------------------------------------------
wazuh.indexer:
<<: *service-defaults
image: wazuh/wazuh-indexer:4.14.8@sha256:6a9c1e3827da627a70859213b3e9cfe43193a492e358953de332b94de33b07de
container_name: soc-wazuh-indexer
hostname: wazuh.indexer
# DIFFERENCE 2: OpenSearch's bootstrap checks want the process able to
# create at least 4096 threads, and 512 would fail that check on start.
pids_limit: 4096
# 2 GiB of heap (below) plus the off-heap the JVM and the security plugin
# take. 3.5 GiB is under Wazuh's stated 4 GiB minimum by the margin the
# guest's 8 GiB has to give the other four services; if the container is
# OOM-killed, this is the number to raise and the guest is the thing to
# grow with it.
mem_limit: 3584m
memswap_limit: 3584m
# DIFFERENCE 5: every capability dropped and no-new-privileges, which
# upstream's compose sets on none of the three (#845). Measured on odin
# on 2026-10-07: this image runs as wazuh-indexer (uid 1000), and every
# process in it — docker-init, sh, java — had CapEff 0000000000000000, so
# the drop takes away only the bounding set: the estate's "belt and
# braces" case on its prometheus, and the same reasoning.
#
# NOT read_only. `docker diff` on the running container: the entrypoint
# creates config/opensearch.keystore (bootstrap.password, kept across
# starts) in the directory where the image ships jvm.options,
# log4j2.properties and the security plugin's defaults, so a tmpfs over
# config/ hides those and the keystore has no setting to live elsewhere.
# It also writes /var/log/wazuh-indexer, which grows, .cache/JNA and /tmp.
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
ulimits:
memlock:
soft: -1
hard: -1
nofile:
soft: 65536
hard: 65536
environment:
# DIFFERENCE from upstream: 2 GiB, not the shipped 1 GiB. ADR-0030's
# arithmetic — 25 shards per GiB of heap, one primary shard per daily
# index, ~15 shards taken by the monitoring/statistics/states families —
# makes 1 GiB a budget spent inside a month. -Xms equal to -Xmx by Wazuh's
# own tuning guidance. Half of system RAM (4 GiB) was considered and
# declined: it buys shard budget a six-agent estate does not need and
# takes page cache from a spinning pair that does.
OPENSEARCH_JAVA_OPTS: "-Xms2g -Xmx2g"
volumes:
- indexer-data:/var/lib/wazuh-indexer
# Issued on odin by the `certs` profile at the foot of this file; the
# runbook is the step. Each tool keeps its own CA (ADR-0030): this is
# the indexer<->manager<->dashboard mTLS, not the lab CA.
- ./wazuh/certs/root-ca.pem:/usr/share/wazuh-indexer/config/certs/root-ca.pem:ro
- ./wazuh/certs/wazuh.indexer.pem:/usr/share/wazuh-indexer/config/certs/wazuh.indexer.pem:ro
- ./wazuh/certs/wazuh.indexer-key.pem:/usr/share/wazuh-indexer/config/certs/wazuh.indexer.key:ro
- ./wazuh/certs/admin.pem:/usr/share/wazuh-indexer/config/certs/admin.pem:ro
- ./wazuh/certs/admin-key.pem:/usr/share/wazuh-indexer/config/certs/admin-key.pem:ro
- ./wazuh/indexer/opensearch.yml:/usr/share/wazuh-indexer/config/opensearch.yml:ro
# RENDERED, not tracked: the file holds bcrypt hashes of two passwords,
# and a hash of a credential in git is a credential in git for anyone
# patient. scripts/render-config.sh writes it from INDEXER_ADMIN_HASH and
# DASHBOARD_PASSWORD_HASH in secrets/soc.sops.yaml — the same shape as
# the estate's snmp.yaml. The tracked wazuh/indexer/internal_users.yml
# keeps the ${PLACEHOLDERS}. Because it is in place BEFORE the first
# start, the security plugin initialises its index from it
# (allow_default_init_securityindex: true in opensearch.yml) and the demo
# passwords never exist on this segment, not even for a minute.
- ./wazuh/indexer/.rendered/internal_users.yml:/usr/share/wazuh-indexer/config/opensearch-security/internal_users.yml:ro
# Not published (ADR-0030's port table): the only clients are this guest's
# manager, dashboard and Alloy, all on this network.
expose:
- "9200"
healthcheck:
# The admin client certificate is the credential, so no password sits in
# this container's environment. `-k` because the leaf is for
# `wazuh.indexer` and this dials localhost — the same loopback trade the
# estate's Grafana check makes with --no-check-certificate. `-f` turns a
# 503 from a security index that has not initialised into "unhealthy",
# which is what gates the manager below.
test:
- CMD-SHELL
- >-
curl -fsk
--cert /usr/share/wazuh-indexer/config/certs/admin.pem
--key /usr/share/wazuh-indexer/config/certs/admin-key.pem
https://localhost:9200/_cluster/health | grep -q '"status"'
interval: 30s
timeout: 10s
retries: 5
start_period: 90s
# ---------------------------------------------------------------------------
# Wazuh manager — the analysis engine the six agents report to.
# ---------------------------------------------------------------------------
wazuh.manager:
<<: *service-defaults
image: wazuh/wazuh-manager:4.14.8@sha256:412f665c77af5497780d29d0d9f069d7a169c5eca45e9a3a47a587d9cfc99b55
container_name: soc-wazuh-manager
hostname: wazuh.manager
# DIFFERENCE 3: no docker-init in front of this one. The image's entrypoint
# is s6-overlay's /init, which is itself a PID-1 supervisor that reaps
# zombies and runs the fifteen wazuh-* daemons plus filebeat. Two init
# systems stacked is one more than either expects; s6 is the one the image
# was built around. Every other service here keeps `init: true`.
init: false
# Fifteen daemons, each multi-threaded, plus filebeat: 512 is too few.
pids_limit: 1024
mem_limit: 2048m
memswap_limit: 2048m
# DIFFERENCE 6: seven capabilities of Docker's default fourteen, each
# measured rather than taken from the documentation (#845). s6, filebeat,
# authd, execd, logcollector, modulesd and syscheckd run as root here;
# remoted, analysisd, wazuh-db, monitord and the API run as wazuh (999).
# A throwaway FIRST boot on odin on 2026-10-07 — fresh volumes, internal
# network, the real ossec.conf — was repeated with one capability more or
# less until it matched an uncapped control run exactly (the same ten
# daemons up, an API token issued), and then with each of the seven taken
# away in turn. Every removal broke the boot; this is what failed:
#
# CHOWN, FOWNER the init's `cp -ar` restoring /var/ossec/queue, and the
# API's chmod of rbac.db: EPERM on files root does not own
# DAC_OVERRIDE root reading /var/ossec/etc, which is 999's and 0770
# SETUID/SETGID the daemons dropping from root to wazuh — "(1131)
# Unable to switch to user" and "(1130) … to group"
# SYS_CHROOT analysisd, remoted and the rest still chroot to
# /var/ossec — "(1132): Unable to chroot"
# KILL wazuh-control's `kill -0` on daemons running as 999,
# which is how the init decides apid "did not start
# correctly" and gives up on the whole manager
#
# The first boot is the case that needs most (it is the one that chowns),
# so the same set covers a warm start; it is also exactly scratch's first
# and only boot. Dropped: FSETID, MKNOD, NET_RAW, NET_BIND_SERVICE (1514,
# 1515 and 55000 are not privileged ports), SETPCAP, SETFCAP, AUDIT_WRITE.
#
# NOT read_only. s6-overlay writes /run, and the cont-init scripts rewrite
# /etc/services.d and /etc/cont-init.d; the daemons write /var/ossec/stats
# and the pid files in /var/ossec/var/run, and something runs dnf,
# touching /var/lib/dnf and /var/cache/dnf — 320 paths in `docker diff`,
# 248 of them outside /run.
# Covering that in tmpfs would be a second copy of the image's layout to
# keep in step with every bump.
cap_drop: [ALL]
cap_add: [CHOWN, DAC_OVERRIDE, FOWNER, KILL, SETGID, SETUID, SYS_CHROOT]
security_opt:
- no-new-privileges:true
ulimits:
memlock:
soft: -1
hard: -1
nofile:
soft: 655360
hard: 655360
environment:
# The literal double quotes inside the value are upstream's and are
# load-bearing: the init script substitutes this verbatim into
# filebeat.yml as `hosts: [$INDEXER_URL]`, and the quotes are what make
# that a YAML string rather than a bare scalar with a colon in it.
INDEXER_URL: '"https://wazuh.indexer:9200"'
INDEXER_USERNAME: admin
INDEXER_PASSWORD: ${INDEXER_PASSWORD:?set in secrets/soc.sops.yaml}
FILEBEAT_SSL_VERIFICATION_MODE: full
SSL_CERTIFICATE_AUTHORITIES: /etc/ssl/root-ca.pem
SSL_CERTIFICATE: /etc/ssl/filebeat.pem
SSL_KEY: /etc/ssl/filebeat.key
# The API account the dashboard signs in with. The image's init writes
# this into the API's user store on first start and never reads it
# again; changing it later is done in the dashboard, not here.
API_USERNAME: wazuh-wui
API_PASSWORD: ${API_PASSWORD:?set in secrets/soc.sops.yaml}
volumes:
- manager-api-configuration:/var/ossec/api/configuration
- manager-etc:/var/ossec/etc
- manager-logs:/var/ossec/logs
- manager-queue:/var/ossec/queue
- manager-var-multigroups:/var/ossec/var/multigroups
- manager-integrations:/var/ossec/integrations
- manager-active-response:/var/ossec/active-response/bin
- manager-agentless:/var/ossec/agentless
- manager-wodles:/var/ossec/wodles
# Vulnerability detection stages its feed here: it downloads the ~400 MB
# compressed content and unpacks it to an ~8.5 GB tar in /var/ossec/tmp
# before importing it. On the container's writable layer that fills the
# 30 GB OS disk on first start; §0's hardware note puts "the vulnerability
# feed" on the data disk, so the staging directory is a bind-backed
# volume there, beside the indexer's data and Velociraptor's datastore.
- manager-tmp:/var/ossec/tmp
- filebeat-etc:/etc/filebeat
- filebeat-var:/var/lib/filebeat
- ./wazuh/certs/root-ca-manager.pem:/etc/ssl/root-ca.pem:ro
- ./wazuh/certs/wazuh.manager.pem:/etc/ssl/filebeat.pem:ro
- ./wazuh/certs/wazuh.manager-key.pem:/etc/ssl/filebeat.key:ro
# The image copies everything under /wazuh-config-mount into /var/ossec
# at start. ossec.conf is upstream's single-node file with one change,
# marked inside it: enrolment requires a password. authd.pass is that
# password, RENDERED by scripts/render-config.sh from
# WAZUH_REGISTRATION_PASSWORD — the file the ADR names — so the
# enrolment secret is never in a tracked file and never in this
# container's environment.
- ./wazuh/manager/ossec.conf:/wazuh-config-mount/etc/ossec.conf:ro
- ./wazuh/manager/.rendered/authd.pass:/wazuh-config-mount/etc/authd.pass:ro
# The `default` group's shared agent config: every Windows agent also
# reads the Sysmon channel (#1035). The agents pull it from here, so
# nothing on the guests changes. The file says why it lives here.
- ./wazuh/manager/shared/default/agent.conf:/wazuh-config-mount/etc/shared/default/agent.conf:ro
# The lab's own rules, over the image's sample local_rules.xml: the
# #449 weaknesses raise alerts that name them, and PowerShell's own
# probe file stops raising "malware dropped" (#1077).
- ./wazuh/manager/rules/local_rules.xml:/wazuh-config-mount/etc/rules/local_rules.xml:ro
ports:
# Agent traffic and enrolment: the six domain machines are the off-host
# consumers (ADR-0012), and they share this segment, so no firewall rule.
#
# DIFFERENCE 8: published on the segment, where the lab's Prometheus and
# Loki went to loopback behind a token proxy (#834). A proxy has nothing
# to add here: these listeners authenticate for themselves. Enrolment
# on 1515 needs authd.pass (above) and issues each agent its own key, and
# 1514 carries only traffic encrypted to a key enrolment issued. Those
# are what stand between the segment and the manager, as the token does
# for the lab's stores. BIND_ADDR narrows it to odin's own address.
- "${BIND_ADDR:-0.0.0.0}:${WAZUH_AGENT_PORT:-1514}:1514"
- "${BIND_ADDR:-0.0.0.0}:${WAZUH_ENROL_PORT:-1515}:1515"
# 55000 is the API: the dashboard reaches it on this network and nothing
# off-host does. 514/udp, which upstream publishes for syslog, is not
# published here at all — nothing on the segment sends syslog to the SIEM,
# and an open UDP listener with no sender is the ADR-0012 case exactly.
expose:
- "55000"
depends_on:
wazuh.indexer:
condition: service_healthy
healthcheck:
# remoted is the daemon the agents talk to, so "up" means remoted is up:
# a manager whose analysis engine runs but whose listener does not is
# exactly the kind of healthy-and-collecting-nothing this repository
# keeps finding. wazuh-control prints one `<daemon> is running...` line
# per daemon and `not running...` otherwise; the probe runs in the
# image's own shell, which check_compose_health.py --probe proves exists.
test:
- CMD-SHELL
- /var/ossec/bin/wazuh-control status | grep -q 'wazuh-remoted is running'
interval: 30s
timeout: 10s
retries: 5
start_period: 120s
# ---------------------------------------------------------------------------
# Wazuh dashboard — agent enrolment, group management, the ruleset editor,
# the vulnerability view and the MITRE mapping. Not a viewer, which is why a
# Grafana OpenSearch datasource does not replace it (ADR-0030).
# ---------------------------------------------------------------------------
wazuh.dashboard:
<<: *service-defaults
image: wazuh/wazuh-dashboard:4.14.8@sha256:dd338d22355e8f0cdf8cc97d5b928539d56061b83c3274a79059e60ad5b8ac14
container_name: soc-wazuh-dashboard
hostname: wazuh.dashboard
mem_limit: 1024m
memswap_limit: 1024m
# DIFFERENCE 7: the estate's whole block — no capabilities, no new
# privileges, read-only (#845). Runs as wazuh-dashboard (uid 1000) with
# CapEff 0. Every write `docker diff` showed lands in a tmpfs, and a
# read-only copy booted on odin on 2026-10-07 against the live indexer:
# login page 200, the Wazuh API reached, templates updated, no EROFS or
# EACCES. What each tmpfs is for:
#
# config/ the entrypoint runs `opensearch-dashboards-keystore create`
# on EVERY start, so the keystore is never state; the image's
# own config/certs (the demo pair) is hidden and unused
# data/ data/uuid. A new server uuid per start costs nothing here
# data/wazuh/downloads, data/wazuh/logs
# created by the app at start. Their own mounts, owned by
# 1000, because Docker makes the mount point for the
# dashboard-config volume below as root, and 1000 cannot then
# mkdir beside it — that was the first boot's fatal error.
# downloads is where a CSV export lands; 64m bounds one.
#
# All of it is memory charged to the 1024m above.
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /usr/share/wazuh-dashboard/config:uid=1000,gid=1000,mode=0750,size=1m
- /usr/share/wazuh-dashboard/data:uid=1000,gid=1000,mode=0750,size=1m
- /usr/share/wazuh-dashboard/data/wazuh/downloads:uid=1000,gid=1000,mode=0755,size=64m
- /usr/share/wazuh-dashboard/data/wazuh/logs:uid=1000,gid=1000,mode=0775,size=16m
- /tmp:size=64m
environment:
INDEXER_USERNAME: admin
INDEXER_PASSWORD: ${INDEXER_PASSWORD:?set in secrets/soc.sops.yaml}
WAZUH_API_URL: https://wazuh.manager
# The dashboard's own service account on the indexer. Its bcrypt hash is
# the second placeholder in wazuh/indexer/internal_users.yml.
DASHBOARD_USERNAME: kibanaserver
DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD:?set in secrets/soc.sops.yaml}
API_USERNAME: wazuh-wui
API_PASSWORD: ${API_PASSWORD:?set in secrets/soc.sops.yaml}
# The image's entrypoint writes these into its wazuh.yml, so the file
# itself is not tracked here and the API password above never lands in
# a file in this checkout. One shard, no replica, for the same reason
# wazuh/indexer/template-wazuh-alerts.json gives the alert indices
# (ADR-0030): on a single node a replica is unassigned forever and the
# cluster is permanently yellow.
WAZUH_MONITORING_SHARDS: "1"
WAZUH_MONITORING_REPLICAS: "0"
volumes:
- ./wazuh/certs/wazuh.dashboard.pem:/usr/share/wazuh-dashboard/certs/wazuh-dashboard.pem:ro
- ./wazuh/certs/wazuh.dashboard-key.pem:/usr/share/wazuh-dashboard/certs/wazuh-dashboard-key.pem:ro
- ./wazuh/certs/root-ca.pem:/usr/share/wazuh-dashboard/certs/root-ca.pem:ro
- ./wazuh/dashboard/opensearch_dashboards.yml:/usr/share/wazuh-dashboard/config/opensearch_dashboards.yml:ro
- dashboard-config:/usr/share/wazuh-dashboard/data/wazuh/config
- dashboard-custom:/usr/share/wazuh-dashboard/plugins/wazuh/public/assets/custom
# NOT published (#1139). Its consumer is a browser on Hicks, but a port
# published here answered all of VLAN 30, the six the SOC watches
# included, and pfSense never sees that traffic to filter it. The stack's
# caddy publishes odin's :443 instead, answers Hicks's /24 only, and
# proxies to this over the soc network. See the Caddyfile.
expose:
- "5601"
depends_on:
wazuh.indexer:
condition: service_healthy
wazuh.manager:
condition: service_started
healthcheck:
# OpenSearch Dashboards answers 503 on / until it has reached the
# indexer, then 302 to the login page. `$$` is compose's escape for the
# regex anchor.
test:
- CMD-SHELL
- >-
curl -sk -o /dev/null -w '%{http_code}' https://localhost:5601/app/login
| grep -qE '^(200|302)$$'
interval: 30s
timeout: 10s
retries: 5
start_period: 120s
# ---------------------------------------------------------------------------
# Velociraptor — the other half of the loop: Wazuh says an alert fired,
# Velociraptor is how you go and ask the endpoint what actually happened.
#
# A container and not the native binary, because this repository's whole
# verification apparatus applies to images (ADR-0030). The ADR flagged the
# one thing to check: whether the registry publishes a versioned tag. It does
# — 0.77.2 is pinned below by tag and digest like every other image here —
# so the paragraph the ADR said to supersede if not stands.
# ---------------------------------------------------------------------------
velociraptor:
<<: *service-defaults
image: ghcr.io/velocidex/velociraptor-server:0.77.3@sha256:6ffb1bd412bad0e280715da19f48491e7ce57068a9840433aaeed8831c2c9ae1
container_name: soc-velociraptor
pids_limit: 1024
# A single Go binary; ADR-0030 sizes it at "a few hundred megabytes of
# RSS". 1 GiB bounds a hunt that collects more than it should.
mem_limit: 1024m
memswap_limit: 1024m
# DIFFERENCE 4: root, with every capability dropped — the Caddy argument
# from stacks/sensitive. The image's entrypoint writes the generated
# config into /etc/velociraptor and the datastore into /datastore, both
# root-owned on first use; a numeric user cannot. Nothing below 1024 is
# bound, so no capability is kept at all.
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
environment:
# The address the six clients dial, compiled into every client config
# and into the MSI. An ADDRESS and not a name on purpose: the enrolment
# path has to survive the exercises the domain exists for, and several
# of those — mitm6, LLMNR and DNS poisoning — are attacks on how a
# Windows host resolves names. A client that cannot find its server
# during the one session that matters teaches nothing.
VELOCIRAPTOR_HOSTNAME: ${VELOCIRAPTOR_HOSTNAME:-10.0.30.60}
VELOCIRAPTOR_DATASTORE_PATH: /datastore/
VELOCIRAPTOR_CONFIG_PATH: /etc/velociraptor/server.config.yaml
# Read once, on the first start, when the entrypoint creates the admin
# account. Change it in the GUI afterwards; this value is then inert.
VELOCIRAPTOR_INITIAL_ADMIN_PASSWORD: ${VELOCIRAPTOR_INITIAL_ADMIN_PASSWORD:?set in secrets/soc.sops.yaml}
volumes:
# The image generates server.config.yaml on first start by running
# /bin/init.vql. This mounts THIS repository's copy of that file over
# the image's, with one change marked inside it: the monitoring
# listener binds every interface, so the lab's Prometheus on alexander
# can scrape 8003 — the image's version binds it to loopback and offers
# no environment variable to move it. The cost, stated: a copy of an
# upstream file that can drift from the image's on a version bump.
- ./velociraptor/init.vql:/bin/init.vql:ro
# The generated config lands here — in the checkout, gitignored,
# because it holds the internal CA's private key, and every client is
# pinned to that CA (ADR-0030: it "cannot be reissued without
# redeploying every client"). Back this directory up with the guest.
- ./velociraptor/etc:/etc/velociraptor
- velociraptor-datastore:/datastore
ports:
# 8000: the client frontend — the six endpoints. 8003: metrics — the
# lab's Prometheus on alexander, genuinely off-host, and "a residual
# rather than a solved problem" in ADR-0030's words: /metrics is
# unauthenticated and so is everything else on that listener. 8001, the
# gRPC API, has no consumer and stays on the container's loopback.
#
# 8889, the GUI, is NOT published from here (#1139): the stack's caddy
# publishes odin's :8889, answers Hicks's /24 only, and proxies to this
# over the soc network — the dashboard's reason, in its ports comment.
- "${BIND_ADDR:-0.0.0.0}:${VELOCIRAPTOR_FRONTEND_PORT:-8000}:8000"
- "${BIND_ADDR:-0.0.0.0}:${VELOCIRAPTOR_METRICS_PORT:-8003}:8003"
healthcheck:
# The same endpoint the lab scrapes, over plain http, from the busybox
# wget the Alpine base ships. If this passes and the scrape from
# alexander does not, the difference is the bind address in init.vql.
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8003/metrics"]
interval: 30s
timeout: 5s
retries: 3
# The first start generates the config, creates the admin user and then
# builds client packages from the release on GitHub. On odin they were a
# Windows MSI and a Linux RPM, with no Debian package among them (read
# from the datastore, 2026-10-10); garuda's .deb is built by
# scripts/stage-agent-msis.sh instead (ADR-0092).
start_period: 120s
# ---------------------------------------------------------------------------
# The agent installers (#1068, ADR-0092). roles/soc_agents installs the Wazuh
# agent and the Velociraptor client on the six directly, and needs both MSIs
# at a URL they can reach; garuda installs the same two as Debian packages by
# its runbook. Nothing in the image builds them: scripts/stage-agent-msis.sh
# puts all four in ${SOC_DATA_DIR}/agent-msi on this guest, the vendor ones
# checked against their pins, and this serves that directory read-only. The
# Caddyfile answers ADR-0029's six addresses and garuda's, and refuses
# everyone else, because each Velociraptor package carries the client's
# enrolment config.
#
# The estate's Caddy, at the digest stacks/lab runs, hardened the same way:
# a numeric user, a read-only root, every capability dropped but one. 8448 is
# above 1024 and needs no privilege to bind, but the image's caddy binary
# carries cap_net_bind_service as a FILE capability, and the kernel refuses
# to exec a binary whose file capabilities are outside the bounding set: with
# [ALL] dropped and no-new-privileges, the first deploy on odin crash-looped
# on "exec caddy failed: Operation not permitted". So NET_BIND_SERVICE is
# kept, as the ingest proxy keeps it, for the exec rather than the bind. The service is `caddy`,
# as in stacks/lab, because scripts/check_caddyfile.sh validates a stack's
# Caddyfile against the image of the service by that name.
#
# Since #1139 it is also the front door to the two web UIs, the Wazuh
# dashboard on odin's :443 and the Velociraptor GUI on :8889, answering
# Hicks's /24 and nothing else; the Caddyfile says why the filter has to be
# here. The container keeps its name: four files call it soc-agent-msi.
# ---------------------------------------------------------------------------
caddy:
<<: *service-defaults
image: caddy:2.11.7@sha256:685ae3d7c34d9b5f8caeddc5d14051db7bbd88ff1f5a2fa0c16bd8ca328fb7af
container_name: soc-agent-msi
# 1000, not 65534, since #1139: the browser-facing leaf is the dashboard's
# own from the Wazuh generator, which writes its key 0400 owned by 1000,
# the uid the dashboard runs as (build-the-soc-guest.md §6). The MSIs are
# 0644 (stage-agent-msis.sh), so 1000 reads them as 65534 did.
user: "1000:1000"
pids_limit: 64
# 64m served two static files. Proxying two UIs streams rather than
# buffers, but a ceiling picked for a file server is not one for a proxy
# carrying the dashboard's bundles, so double it until measured.
mem_limit: 128m
memswap_limit: 128m
cap_drop: [ALL]
cap_add: [NET_BIND_SERVICE]
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /data:size=1m,uid=1000,gid=1000
- /config:size=1m,uid=1000,gid=1000
- /tmp:size=1m
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ${SOC_DATA_DIR:-/srv/soc-data}/agent-msi:/srv/agent-msi:ro
# The browser-facing leaf on :443 and :8889, and the root the dashboard
# upstream is verified against. Mounted where scripts/check_caddyfile.sh
# mounts its throwaway pair, so the check proves these paths.
- ./wazuh/certs/wazuh.dashboard.pem:/etc/caddy/tls/cert.pem:ro
- ./wazuh/certs/wazuh.dashboard-key.pem:/etc/caddy/tls/key.pem:ro
- ./wazuh/certs/root-ca.pem:/etc/caddy/tls/ca.pem:ro
ports:
# The six domain guests fetch from here during `--tags soc`. Fixed, not
# a tunable: ansible/inventory/group_vars/all.yaml names this port in
# both URLs, and a second copy of a number is how they drift apart.
- "${BIND_ADDR:-0.0.0.0}:8448:8448"
# The two UIs (#1139), on the host ports they were published on before,
# so a bookmark on Hicks and the start page's links still work. 8443 inside,
# so nothing below 1024 is bound in the container.
- "${BIND_ADDR:-0.0.0.0}:${WAZUH_DASHBOARD_PORT:-443}:8443"
- "${BIND_ADDR:-0.0.0.0}:${VELOCIRAPTOR_GUI_PORT:-8889}:8889"
# No depends_on on either UI: Caddy resolves an upstream per request, and
# the MSIs must not wait on the indexer's two-minute start.
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8448/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
# ---------------------------------------------------------------------------
# Docker socket proxy (#836). The estate's docker-socket-proxy (#193); its
# comment in stacks/observability/compose.yaml is the reasoning. `:ro` on
# the socket does not stop POST /containers/create, so a collector holding the
# socket holds root on the host, and odin holds the SOC's age key and
# Velociraptor's CA.
# Alloy now reaches the API here, GET-only, and the socket stops at this
# container. Not published.
# ---------------------------------------------------------------------------
docker-socket-proxy:
<<: *service-defaults
image: tecnativa/docker-socket-proxy:v0.5.0@sha256:1f5038b54f06c3e18422902cf00ba21803d1c97805aae032e5e6673d532d3459
container_name: soc-docker-socket-proxy
# stacks/observability's HAProxy config, not the image's template, which
# with CONTAINERS=1 also allows /containers/{id}/archive and /export
# (Tecnativa/docker-socket-proxy#182). The estate's proxy has the reasoning.
command: ["haproxy", "-f", "/usr/local/etc/socket-proxy/haproxy.cfg"]
environment:
# The estate's allowlist exactly: what docker.alloy's three components
# were measured to need (#193). NETWORKS is the one that is not obvious;
# without it discovery.docker fails and container logs stop while the
# agent stays healthy.
CONTAINERS: 1
IMAGES: 1
INFO: 1
VERSION: 1
EVENTS: 1
NETWORKS: 1
POST: 0
BUILD: 0
COMMIT: 0
CONFIGS: 0
DISTRIBUTION: 0
EXEC: 0
NODES: 0
PLUGINS: 0
SECRETS: 0
SERVICES: 0
SESSION: 0
SWARM: 0
SYSTEM: 0
TASKS: 0
VOLUMES: 0
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ../observability/docker-socket-proxy:/usr/local/etc/socket-proxy:ro
expose:
- "2375"
mem_limit: 64m
memswap_limit: 64m
# Not read_only, for the estate's measured reason: the entrypoint writes
# haproxy.cfg from the environment at every start.
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
healthcheck:
# The estate's probe (#845); stacks/observability/compose.yaml's
# docker-socket-proxy has the reasoning.
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:2375/version"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
# ---------------------------------------------------------------------------
# Collection agent for this guest — and the first genuine off-host client
# the lab's Prometheus and Loki have ever had (ADR-0030).
#
# config.alloy and docker.alloy are MOUNTED from stacks/observability/alloy/,
# unchanged, for the reason stacks/lab/compose.yaml gives at length: a copy
# is reused-until-someone-edits-one. What this stack adds is one file of its
# own, alloy/opensearch.alloy, which lives HERE and not beside the shared
# two — the estate's Alloy mounts that whole directory, and a file placed
# there would load on VLAN 99 pointing at an OpenSearch that does not exist:
# a healthy exporter collecting nothing, one layer along (ADR-0030).
# ---------------------------------------------------------------------------
alloy:
<<: *service-defaults
image: grafana/alloy:v1.20.1@sha256:2aa2099af76c0098d4af7a4d6e48f86cb66dc1a000222ad927a1c67c6542d13f
container_name: soc-alloy
pids_limit: 1024
# A bound, not a measurement: re-derive from process_resident_memory_bytes
# {job="odin-alloy"} in the lab's Prometheus after a fortnight, as zeek's
# says. (Per-container container_memory_rss is not kept there: the lab's
# cAdvisor series are the root cgroup only.) 299 MiB was the peak over
# the 9.7 days odin had run on 2026-10-07, median 287 — flat, with no
# excursion yet — and 1024m is the estate's ~3.5x on top (#114).
# The neighbour is why it matters: an unbounded Alloy that runs away is
# what the kernel OOM-kills wazuh.indexer for (#845).
mem_limit: 1024m
memswap_limit: 1024m
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
group_add:
- "${LOG_READ_GID:?run make render STACK=soc}"
# The image's own `alloy` group; alloy-data is 0770 473:473. Re-derive
# after an image bump — the estate's compose file has the command.
- "473"
cgroup: host
environment:
# ADR-0007's "the two *_URL variables are the only difference", and here
# they finally point OFF the host: at alexander, the lab's stores. That
# is the line stacks/lab/compose.yaml was written waiting for, and it
# only works once the lab's ports: blocks are uncommented — the runbook
# does both on the same day. Still nothing remote-writes to 10.0.99.20.
LOKI_URL: http://${LAB_HOST:-10.0.30.40}:${LAB_LOKI_PORT:-3100}/loki/api/v1/push
PROMETHEUS_REMOTE_WRITE_URL: http://${LAB_HOST:-10.0.30.40}:${LAB_PROMETHEUS_PORT:-9090}/api/v1/write
ALLOY_HOSTNAME: ${ALLOY_HOSTNAME:-alloy}
# Read by docker.alloy's three components; the socket is not mounted (#836).
DOCKER_API: tcp://docker-socket-proxy:2375
# For the exporter in opensearch.alloy. Read through sys.env() there, so
# the credential is in this container's environment (the same class of
# exposure as GRAFANA_ADMIN_PASSWORD) and in no file.
INDEXER_PASSWORD: ${INDEXER_PASSWORD:?set in secrets/soc.sops.yaml}
# odin's token for the lab's ingest proxy on alexander (#834). Its other
# copy is INGEST_TOKEN_ODIN in secrets/lab.sops.yaml; config.alloy sends
# it as a bearer token on both pushes.
INGEST_TOKEN: ${INGEST_TOKEN:?set in secrets/soc.sops.yaml}
command:
- run
- --server.http.listen-addr=0.0.0.0:12345
- --storage.path=/var/lib/alloy/data
- /etc/alloy
# No depends_on, unlike the lab's Alloy. This is the guest's own collector
# as well as the exporter's host; an indexer that will not come up is
# precisely when the guest's node metrics and container logs are wanted on
# alexander.
volumes:
- ../observability/alloy/config.alloy:/etc/alloy/config.alloy:ro
- ../observability/alloy/docker.alloy:/etc/alloy/docker.alloy:ro
- ./alloy/opensearch.alloy:/etc/alloy/opensearch.alloy:ro
# The indexer's root CA, so the exporter verifies `wazuh.indexer` rather
# than skipping verification — the same standard the lab's Prometheus
# holds its Grafana scrape to.
- ./wazuh/certs/root-ca.pem:/etc/alloy/tls/root-ca.pem:ro
- alloy-data:/var/lib/alloy/data
- /var/log:/var/log:ro
- /:/rootfs:ro
# /rootfs/run masked: stacks/lab/compose.yaml's alloy has the reason.
tmpfs:
- /rootfs/run:size=64k,mode=0755
depends_on:
docker-socket-proxy:
condition: service_healthy
ports:
# Debug UI, loopback only. No syslog receiver: syslog.alloy is not
# mounted above, for the reason stacks/lab gives.
- "127.0.0.1:${ALLOY_PORT:-12345}:12345"
healthcheck:
# The estate's probe (#845); stacks/observability/compose.yaml's alloy
# has the reasoning.
test:
- CMD
- bash
- -c
- >-
exec 3<>/dev/tcp/127.0.0.1/12345
&& printf 'GET /-/ready HTTP/1.0\r\n\r\n' >&3
&& head -n1 <&3 | grep -q ' 200 '
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
# ---------------------------------------------------------------------------
# The certificate generator, behind a profile: `make up` neither starts nor
# pulls it. Run once on odin, before the first start — build-the-soc-guest.md
# is the step — and again only to reissue. It is a service here rather than
# a bare `docker run` in the runbook because every image this repository
# runs must come from a compose.yaml (check_image_pins.py, #65).
#
# The image's own entrypoint is NOT run (#1008, ADR-0094). It downloads the
# cert tool at run time with no checksum, falls back silently to Wazuh's dev
# bucket, and sources the result as root over the certificate directory.
# scripts/wazuh-certs-mint.sh runs the tool vendored in wazuh/certs-tool/
# instead, refuses one whose sha256 isn't the one VENDORED pins, and does the
# same copy and chown steps after it. With no network at all, there is no
# download to fall back from.
#
# The vendored tool is the one thing Dependabot cannot keep in step with the
# wazuh/* major.minor above: a bump to 4.15 runs
# `scripts/vendor-wazuh-certs-tool.sh 4.15` in the same PR, and that diff
# is the review.
# ---------------------------------------------------------------------------
wazuh.certs-generator:
image: wazuh/wazuh-certs-generator:0.0.4@sha256:369b4d58509aab074b188596870c81584f7120e653d9ef83c591f0f785dcf325
container_name: soc-wazuh-certs-generator
profiles: ["certs"]
logging: *default-logging
network_mode: none
entrypoint: ["/bin/bash", "/wazuh-certs-mint.sh"]
volumes:
- ./wazuh/certs:/certificates
- ./wazuh/certs.yml:/config/certs.yml:ro
- ./wazuh/certs-tool:/certs-tool:ro
- ../../scripts/wazuh-certs-mint.sh:/wazuh-certs-mint.sh:ro
# One network. Everything that talks to the indexer does so here; the only
# things published are the ones with a client on the other side of the
# container boundary, listed per service above.
networks:
soc:
driver: bridge
volumes:
# The things that grow live on odin's second disk, not the OS volume
# (#439). Velociraptor's collections get large fast, and ADR-0030 sized the
# indexer by heap on the assumption that disk was not the constraint; a
# separate filesystem makes that assumption checkable and gives the
# retention arithmetic somewhere to be wrong safely. Named volumes backed by
# a bind, so every service definition above stays as it was.
#
# The directories must exist on the MOUNTED disk: build-the-soc-guest.md §3
# makes the empty mountpoint immutable, so with the disk unmounted neither
# path can exist and `make up` fails on "no such file or directory" rather
# than quietly filling the OS disk with evidence.
indexer-data:
driver: local
driver_opts:
type: none
o: bind
device: ${SOC_DATA_DIR:-/srv/soc-data}/indexer
manager-api-configuration:
manager-etc:
manager-logs:
# /var/ossec/queue holds vulnerability detection's content database
# (queue/vd), which after the first feed import is ~8.5 GB and grows with the
# feed — the persistent half of what §0 means by "the vulnerability feed", as
# manager-tmp below is the staging half. On the OS volume it fills the 30 GB
# disk, so it too is bind-backed to the data disk. The agent event queue
# lives here as well, which wants the data disk anyway.
manager-queue:
driver: local
driver_opts:
type: none
o: bind
device: ${SOC_DATA_DIR:-/srv/soc-data}/manager-queue
manager-var-multigroups:
manager-integrations:
manager-active-response:
manager-agentless:
manager-wodles:
filebeat-etc:
filebeat-var:
dashboard-config:
dashboard-custom:
velociraptor-datastore:
driver: local
driver_opts:
type: none
o: bind
device: ${SOC_DATA_DIR:-/srv/soc-data}/velociraptor
# The manager's vulnerability-feed staging (see the service above): the third
# thing that grows, and the one §0 means by "the vulnerability feed". Bound to
# the data disk so the ~8.5 GB unpack cannot fill the OS disk.
manager-tmp:
driver: local
driver_opts:
type: none
o: bind
device: ${SOC_DATA_DIR:-/srv/soc-data}/manager-tmp
alloy-data: