Deployment Guide
How to run Wildbox on a server you control. Wildbox is in an early evaluation phase: it is suitable for testing, staging and community deployments, and real-world deployment feedback is welcome in Discussions and Issues.
This guide builds on the Quick Start; read it first. Ports and service names are listed once, in Service ports.
1. Server Requirements
- Linux with Docker Engine 23.0 or later and the Compose plugin 2.24.4 or
later:
- Compose 2.24.4, because
docker-compose.prod.ymluses the!overridetag, which older versions reject; - Engine 23.0, because the images are built with named build contexts
(
additional_contextsindocker-compose.yml, to copyopen-security-sharedinto each service). Named contexts need BuildKit 0.10 (Dockerfile frontend 1.4), and Engine 23.0 is the first release that ships it and builds with BuildKit by default
- Compose 2.24.4, because
- 8 GB RAM minimum (16 GB recommended), 50 GB SSD
- A DNS name for the server and a TLS certificate for it
- Somewhere off the server to keep backups
Open only what the gateway needs. Every other service is bound to 127.0.0.1
by docker-compose.yml, and PostgreSQL and Redis publish nothing:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp # SSH; restrict to your addresses if you can
sudo ufw allow 443/tcp # HTTPS through the gateway
sudo ufw allow 80/tcp # optional: /health; the redirect on this port names https://api.wildbox.local, not your host
sudo ufw enable
Docker publishes ports through its own iptables rules, which can bypass ufw.
That is why the backend ports are bound to 127.0.0.1 in the Compose file;
do not change those bindings.
2. Get the Code and Generate Secrets
git clone https://github.com/fabriziosalmi/wildbox.git
cd wildbox
make generate-secrets
make validate-secrets
Then edit .env and set the values that describe your deployment rather
than secrets:
INITIAL_ADMIN_EMAIL: the first administrator’s loginCORS_ORIGINS: the other HTTPS origins whose pages may call the API from a browser, comma-separated, for examplehttps://wildbox.example.com(here and below, replacewildbox.example.com, a name reserved for documentation, with your host name). The gateway answers a preflight from these origins and names them in its responses, and only them; the production overlay passes the setting to the gateway and to identity, tools, guardian, responder and agents, anddocker-compose.ymlto data. guardian receives it asCORS_ALLOWED_ORIGINSand reads it by the gateway’s rules (guardian/cors.py): origins separated by commas, or a JSON list of them, and an entry that is not an origin (a wildcard, a path, a trailing slash) stops guardian at start-up with a message naming it. Each entry is an origin: a scheme, a host and an optional port, with no path and no wildcard. The gateway does not start with an entry that is not one. A JSON list is accepted too. It can be left empty when the dashboard and the API share the gateway’s origin, as they do in this stack: an empty value allows no cross-origin requests, and same-origin requests need noneENVIRONMENT=production(the template default). Required: Compose refuses to start without it, anddocker-compose.prod.ymlsetsproductionon every service that reads it, whatever.envsays. Onlydevelopmentserves the API schemas and skips the start-up checks for secretsNEXT_PUBLIC_GATEWAY_URL: leave it empty. The gateway serves the dashboard, and an empty value makes the dashboard call the API on the origin it was loaded from. See The dashboard’s browser settings for when to set it
Do not generate secrets by hand or copy them from documentation. The Credentials guide explains every generated value and how to rotate it.
3. TLS Certificate
The gateway reads its certificate from open-security-gateway/ssl/, mounted
at /etc/ssl/wildbox/:
open-security-gateway/ssl/wildbox.crt: certificate, with the full chainopen-security-gateway/ssl/wildbox.key: private key
If either file is missing when the gateway starts, it generates a self-signed development certificate and key there, replacing the one that is present. For a real deployment, put your certificate in place before the first start, for example from Let’s Encrypt:
sudo certbot certonly --standalone -d wildbox.example.com
sudo install -m 0644 /etc/letsencrypt/live/wildbox.example.com/fullchain.pem \
open-security-gateway/ssl/wildbox.crt
sudo install -m 0600 /etc/letsencrypt/live/wildbox.example.com/privkey.pem \
open-security-gateway/ssl/wildbox.key
TLS terminates at the gateway; no other proxy is needed in front of it.
certbot --standalone needs port 80 free, so run it before the stack starts or
stop the gateway while it renews. After replacing the files, restart the
gateway: docker compose restart gateway.
4. Start the Stack
make start-prod composes docker-compose.yml with docker-compose.prod.yml
(restart: always, log rotation, and network segmentation: only the
gateway, the dashboard and the sensor share the public-facing network, and PostgreSQL and Redis sit on an internal network reachable only
by the services that use them; the map is at the top of
docker-compose.prod.yml). To check it on a host:
python3 scripts/check_network_segmentation.py config, and with the stack
running, COMPOSE_FILE=docker-compose.yml:docker-compose.prod.yml python3
scripts/check_network_segmentation.py runtime.
make start-prod
docker compose -f docker-compose.yml -f docker-compose.prod.yml ps
Databases are created by scripts/init-databases.sql on PostgreSQL’s first
start, and the identity service creates the first administrator from
INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD. Nothing needs to be created
by hand.
With the production overlay, give every docker compose command in this
guide the files you started the stack with,
-f docker-compose.yml -f docker-compose.prod.yml, or set
COMPOSE_FILE=docker-compose.yml:docker-compose.prod.yml once in the shell.
Without them Compose reads docker-compose.yml alone and recreates the
services it touches from that file: on the flat development network and
without the overlay’s settings.
Optional services:
export COMPOSE_FILE=docker-compose.yml:docker-compose.prod.yml
docker compose --profile automations up -d # n8n workflows
docker compose --profile monitoring up -d # Prometheus and Alertmanager (section 7)
docker compose --profile backup up -d # scheduled PostgreSQL and Redis backups
The dashboard’s browser settings
The dashboard is a Next.js application, and Next.js writes every
NEXT_PUBLIC_* variable into the JavaScript it sends to the browser when the
image is built. The production overlay passes them as build arguments,
read from .env; setting them on the running container changes nothing.
After changing one, rebuild the dashboard image:
docker compose -f docker-compose.yml -f docker-compose.prod.yml build dashboard
make start-prod
| Variable | Default | Set it when |
|---|---|---|
NEXT_PUBLIC_GATEWAY_URL |
empty: the API is called on the dashboard’s own origin | the dashboard is served from a different origin than the gateway. Use the gateway’s public HTTPS origin, for example https://api.wildbox.example.com; it is also added to the dashboard’s Content-Security-Policy connect-src. List the dashboard’s own origin in CORS_ORIGINS, or the gateway refuses its browser’s calls |
NEXT_PUBLIC_USE_GATEWAY |
true |
never in production; false is for development against a bare service |
NEXT_PUBLIC_APP_URL |
empty (http://localhost:3000 in page metadata) |
you want absolute links in the page metadata to name your host, for example https://wildbox.example.com |
In this stack the gateway serves the dashboard and the API on one origin, so
the defaults are what a deployment needs, whatever host name it is reached
by. Images built before this setting existed fell back to
http://localhost:80: in a browser on any other machine every API call,
the login first, went to that machine and failed.
The gateway’s per-team rate limit
RATE_LIMIT_PER_HOUR in .env is the number of API requests a team may make
in an hour, through the gateway, on every authenticated route. The default is
- The gateway enforces it per minute, as one sixtieth of the hourly
figure (at least one request a minute), and reports it on every response in
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-ResetandX-RateLimit-Policy. The value must be a whole number between 1 and 1000000000: with any other value the gateway logsRATE_LIMIT_PER_HOUR must be a whole number ...and does not start. Restart the gateway after changing it (docker compose up -d gateway).
The gateway’s per-address rate limits
Before it authenticates anything, the gateway limits how fast one client
address may send. Past the limit it answers nginx’s own 429, an HTML page
without Retry-After. Three settings in .env hold the rates, in requests
per second:
| Setting | Default | Applies to | Burst |
|---|---|---|---|
GATEWAY_RATE_LIMIT_PER_SECOND |
100 |
every route that has no limit of its own | 10 |
GATEWAY_AUTH_RATE_LIMIT_PER_SECOND |
5 |
login (/auth/jwt/), registration and forgotten password, counted together |
3, 2 and 2 |
GATEWAY_STATIC_RATE_LIMIT_PER_SECOND |
500 |
the dashboard’s static assets | 200 |
The defaults are the limits the gateway always had; a deployment that sets nothing keeps them. What they protect: the first keeps one address from flooding the gateway and, behind it, identity, since a request without a valid credential is refused here before anything is asked of identity; the second slows password guessing, mass registration and forgotten-password requests from one address (identity also locks an account after repeated failed logins, whatever this rate is); the third only keeps one address from hammering the assets.
The bursts in the last column are not settings. A burst is how far ahead of
its rate one address may get: of the requests an address sends at the same
instant the gateway serves the burst plus one (11 under the first setting, 4
on the login route, 3 on registration and on forgotten password, 201 for
static assets) and answers 429 to the rest, whatever the rate is. After
that it makes room for one more request every 1/rate seconds. With the
defaults, 30 API requests that arrive from one address within 50 ms get
about 16 answers and 14 refusals: the 11 of the burst, and 5 for the 50 ms
at 100 a second. A page that starts many requests at once, or many users behind one
NAT address, meet this limit and not the rate. Raising
GATEWAY_RATE_LIMIT_PER_SECOND makes room again sooner (every millisecond
at 1000, where it is every 10 ms at 100), so a volley spread over some
milliseconds passes; it does not change how many requests may arrive in the
same millisecond, which stays 11. The bursts are written beside the routes
in open-security-gateway/nginx/conf.d/wildbox_gateway.conf; no setting in
.env changes them.
These are operator settings. The gateway counts by the address of the connection, never by a header, so nothing a client sends changes a limit or moves it to another counter. That also means that behind a NAT, a load balancer or another reverse proxy every user reaches the gateway from one address and shares one allowance: that is when to raise a rate. Raising the second gives a client that guesses passwords that many more attempts a second.
Each value must be a whole number from 1 to 100000, in digits only. With
any other value the gateway logs
GATEWAY_RATE_LIMIT_PER_SECOND must be a whole number of requests per second between 1 and 100000 ...
(with the name of the setting at fault) and does not start. At start it logs
the rates it runs with:
Per-address request limits: global 100 r/s, auth 5 r/s, static assets 500 r/s.
Restart the gateway after changing one (docker compose up -d gateway); an
nginx reload does not read them.
The repository’s test suites send every request from one address, faster than these defaults allow, so the stacks CI starts for them set the three rates to 10000. Do not copy that into a deployment.
Guardian’s per-user rate limit
Under the gateway’s limit, guardian allows each user
GUARDIAN_RATE_LIMIT_USER requests to its own API: 1000/hour unless .env
sets it. The value is <count>/<period> with a period of second, minute,
hour or day (for example 20/minute), or off to rely on the gateway’s
limit alone. The count is kept per user, as the gateway identifies the user,
so one member of a team cannot use up guardian for the others. With any other
value guardian logs GUARDIAN_RATE_LIMIT_USER=...: expected <count>/<period>
... and does not start. Restart guardian after changing it
(docker compose up -d guardian). Guardian’s health check is not rate
limited.
Redis memory
Redis is not a cache here. It holds the token blacklist, failed-login lockout
counters, Celery and Dramatiq queues and results, and scan, run and task state
that exists nowhere else. It therefore runs with
--maxmemory-policy noeviction, in production as in development: an eviction
policy would delete those keys under memory pressure, so a revoked token
would work again and a lockout would lift early.
What happens at the limit: once the dataset reaches REDIS_MAXMEMORY, Redis
refuses every command that would add memory with
OOM command not allowed when used memory > 'maxmemory'. Reads, deletes and
PING keep working, nothing already stored is lost, and Redis and its health
check stay up. The service that issued the write gets the error, so new tasks
cannot be queued and new state cannot be recorded until memory is freed or
the limit is raised. Raise it and restart Redis:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d wildbox-redis.
Two settings in .env size it:
| Variable | Default | Meaning |
|---|---|---|
REDIS_MAXMEMORY |
1gb |
The dataset ceiling at which writes are refused |
REDIS_MEMORY_LIMIT |
2g |
The container’s memory limit |
Keep REDIS_MEMORY_LIMIT at least twice REDIS_MAXMEMORY. maxmemory
bounds the dataset, not the process: an AOF rewrite forks, and copy-on-write
under writes can double resident memory. When the container limit is lower,
the kernel kills Redis (exit 137) before noeviction gets to refuse anything,
and up to the last second of writes (appendfsync everysec) is lost. Measured on redis:7-alpine
with a 128 MB maxmemory: a limit equal to it was OOM-killed while filling,
a limit of 1.5x was killed during an AOF rewrite, and at 2x Redis refused the
excess writes and stayed up. Check a configuration before starting it:
python3 scripts/check_redis_config.py config --env-file .env
Monitor the headroom and alert well before the ceiling, for example when
used_memory passes 80% of maxmemory, and on any increase of
errorstat_OOM, the count of refused writes:
REDISCLI_AUTH="$(sed -n 's/^REDIS_PASSWORD=//p' .env)" \
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec \
-e REDISCLI_AUTH wildbox-redis \
sh -c 'redis-cli INFO memory | grep -E "^(used_memory|maxmemory):"; redis-cli INFO errorstats'
-e REDISCLI_AUTH names the variable and takes its value from the
environment of the command, so the password is not an argument of docker,
where the process list of the host would show it.
With the stack running,
COMPOSE_FILE=docker-compose.yml:docker-compose.prod.yml python3
scripts/check_redis_config.py runtime --env-file .env reads the live settings
and the applied limit and prints the memory in use and its peak.
guardian’s scheduled tasks
guardian-beat sends guardian’s periodic tasks to guardian-worker. The
schedule is defined in open-security-guardian/guardian/schedule.py; when
guardian-beat starts it writes each entry into django-celery-beat’s
PeriodicTask table. Crontab times are in CELERY_TIMEZONE, UTC unless set.
| Task | Default | Why | Variable |
|---|---|---|---|
| SLA violation check | every 15 minutes | The shortest SLA is 4 hours (P1), so a breach is reported within 15 minutes of it. The assignee is e-mailed at most once every 24 hours per vulnerability, however often the check runs; the team’s owners and admins, when there is no assignee to tell, once (see Notification recipients) | GUARDIAN_SCHEDULE_SLA_CHECK |
| Alert rules | every 15 minutes | A condition is noticed within 15 minutes of becoming true. A rule notifies when it starts firing and when it recovers, not on every evaluation (below), so a shorter interval detects sooner without sending more mail | GUARDIAN_SCHEDULE_ALERT_RULES |
| Risk score recalculation | daily, 02:00 | A full pass over open vulnerabilities, so off-peak. Edits already recalculate one vulnerability at a time; the pass catches what does not, such as a change to an asset’s criticality | GUARDIAN_SCHEDULE_RISK_SCORES |
| Expired report cleanup | daily, 03:00 | Reports expire 30 days after generation; a day’s precision is enough | GUARDIAN_SCHEDULE_REPORT_CLEANUP |
| Vulnerability history cleanup | daily, 03:30 | One year of history is kept; running daily keeps each deletion to one day of rows | GUARDIAN_SCHEDULE_HISTORY_CLEANUP |
| Asset inventory | daily, 04:30 | Marks assets not seen for 30 days inactive | GUARDIAN_SCHEDULE_ASSET_INVENTORY |
| Overdue compliance assessments | daily, 08:00 | Sends one reminder per overdue assessment on every run, at the start of the working day, to the owners and admins of the assessment’s team (see Notification recipients) | GUARDIAN_SCHEDULE_OVERDUE_ASSESSMENTS |
| Expiring compliance exceptions | Mondays, 08:00 | Looks 30 days ahead and prepares a reminder on every run: weekly gives about four reminders per exception, daily would give thirty. Sent to the owners and admins of the exception’s team | GUARDIAN_SCHEDULE_EXPIRING_EXCEPTIONS |
| User-defined schedules | every minute | Queues the discovery rules and report schedules that are due (below). Their cron fields have a one-minute resolution, so each starts within a minute of its time; a sweep that finds nothing due is two indexed queries | GUARDIAN_SCHEDULE_USER_SCHEDULES |
To change one, set its variable in .env and restart the scheduler:
# Every 10 minutes; five crontab fields; or off.
GUARDIAN_SCHEDULE_SLA_CHECK=600
GUARDIAN_SCHEDULE_REPORT_CLEANUP="15 1 * * *"
GUARDIAN_SCHEDULE_EXPIRING_EXCEPTIONS=off
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d guardian-beat
docker compose logs guardian-beat
- A value is a number of seconds, five crontab fields (
minute hour day-of-month month day-of-week) oroff, which disables the task. An invalid value stopsguardian-beatat start-up with the variable’s name in the error. - The variables are the source of truth. An edit to one of these entries in
the
PeriodicTasktable lasts untilguardian-beatrestarts, when the configured value is written back. guardian serves no Django admin site. - A run that is still queued when the next one is due (for the daily and weekly tasks, an hour after its slot) is dropped rather than run late, so a worker that was down does not come back to a burst of identical reminders. The sweeps that send notifications or rewrite every vulnerability also skip a run while another one is still in progress.
- Run exactly one
guardian-beat. Beat has no leader election: a second instance would send every task twice. The service has a fixed container name, so--scale guardian-beat=2fails. - Its health check reads a heartbeat file the scheduler refreshes after every tick (at least every 5 seconds); the container turns unhealthy when the file is older than a minute, that is when beat is running but no longer scheduling.
- The e-mails these tasks send, and what they need to be sent at all, are described under Notification recipients and E-mail below.
Schedules defined through the API
The dispatcher in the last row runs the schedules users create:
| Schedule | Defined by | Runs | What can be scheduled |
|---|---|---|---|
Asset discovery rule (/api/v1/guardian/assets/discovery-rules/) |
schedule: five crontab fields, in CELERY_TIMEZONE (UTC unless set), with the same syntax as the variables above |
the rule’s network scan, on the scanning queue |
network_scan rules only. Cloud API and CMDB discovery are placeholders and agent reports and DNS zone transfers have no code, so the API refuses those types |
Report schedule (/api/v1/guardian/reports/schedules/) |
next_run (the first run) and frequency: once, daily, weekly, monthly or quarterly |
a report, generated on the reporting queue and e-mailed to the schedule’s recipients when it is ready; a schedule without recipients sends no e-mail (see Notification recipients) |
vulnerability summary, asset inventory, compliance status and executive dashboard reports, as JSON or HTML. The other report types have no data behind them and the other formats are not written yet, so the API refuses them |
Scan schedule (/api/v1/guardian/scanners/scan-schedules/) |
cron_expression |
nothing | nothing: guardian cannot start a scan on an external scanner yet, so the API has no route that creates, changes, triggers or enables one. Existing ones can still be listed, disabled and deleted |
- Each due time runs once. The dispatcher claims a run by moving
next_runon in the same statement that checks it, so two overlapping sweeps, or a sweep and a manual run, cannot both queue it;last_runrecords when it was queued. - A schedule that missed several runs (guardian was down, or the schedule
was paused) runs once and then continues from the next run after now; the
missed ones are not replayed. A one-off report schedule is set to
disabledonce it has run. - An invalid cron expression is refused by the API. One written another way (straight into the database) is logged by every sweep and never run.
- Reports are written to the
guardian_mediavolume, whichguardian-worker(which generates them) andguardian(which serves their downloads) share.
Alert rules
An alert rule (/api/v1/guardian/reports/alerts/) names a metric in
data_source, narrows it with filters in condition_config, and fires when
the value compares with threshold_value as operator says (gt, gte,
lt, lte, eq, ne). Only threshold conditions are evaluated; the API
refuses change, trend and anomaly, and any metric or filter not listed
here.
| Metric | Value | Filters |
|---|---|---|
vulnerabilities.unresolved |
vulnerabilities open or in progress | severity (list), asset (id) |
vulnerabilities.overdue |
open vulnerabilities past their due date, as the SLA check counts them | severity, asset |
vulnerabilities.max_risk_score |
highest risk score (0-10) among unresolved vulnerabilities, 0 when there are none | severity, asset |
compliance.non_compliant_results |
compliance results found non-compliant | risk_level (list) |
compliance.overdue_assessments |
planned or in-progress assessments past their due date | none |
For example, “more than 5 unresolved critical vulnerabilities”:
{"data_source": "vulnerabilities.unresolved", "condition_config":
{"severity": ["critical"]}, "condition_type": "threshold", "operator": "gt",
"threshold_value": 5}.
- A rule notifies when it starts firing, once when it recovers, and while it
keeps firing at most once per
GUARDIAN_ALERT_RENOTIFY_INTERVAL(seconds, default 86400, orofffor no reminders). A day, because a condition still true after a day is a backlog to be reminded of, like the SLA check’s daily reminder, and not news every 15 minutes. - Notifications are e-mailed to
notification_config.recipients, or to the owners and admins of the rule’s team when it names none (see Notification recipients). Each notification is recorded with who it was addressed to, whether it was delivered and, when it was not, why (failure_reason), andGET /api/v1/guardian/reports/alerts/{id}/notifications/lists them. - The rule shows its
state(okorfiring),firing_since,last_valueandlast_evaluated_at.trigger_countcounts the times it started firing.
Team memberships
guardian lets a team name only its own members: as the assignee of a vulnerability, the owner of an asset, the people a dashboard is shared with. identity owns memberships, so guardian learns of them in two ways:
- Every request the gateway authenticates tells guardian that the user is
in the team now. guardian counts a user as a member for
GUARDIAN_TEAM_MEMBERSHIP_MAX_AGE_DAYSfrom their last request. - When a member is removed from a team, or an account is deleted, identity
tells guardian at
GUARDIAN_INTERNAL_URL, after it has made the change. guardian stops accepting the user at once and clears the roles they held in that team.
| Variable | Read by | Default | Meaning |
|---|---|---|---|
GUARDIAN_TEAM_MEMBERSHIP_MAX_AGE_DAYS |
guardian, guardian-worker, guardian-beat |
30 |
Days a user stays one of a team’s users without making a request in it, from 1 to 365. Any other value stops the three containers at start-up: there is no way to switch the window off, since that would keep a former member for good. Shorter bounds a lost notice more tightly; a member who has not opened guardian for longer than this cannot be assigned work until their next request |
GUARDIAN_INTERNAL_URL |
identity |
http://open-security-guardian:8013/internal/team-memberships/revoke/ |
Where identity tells guardian that a membership ended. Set it to an empty value only in a deployment that does not run guardian: identity then sends nothing |
- The notice does not go through the gateway, which proxies guardian’s
/api/v1/only. identity and guardian share a network in both Compose files, and guardian refuses the notice withoutGATEWAY_INTERNAL_SECRET, which both already have. -
Removing a member does not wait for guardian. If guardian does not confirm the notice (three attempts), the member is removed all the same and identity logs an error that begins
guardian was not told of. The gateway already refuses the member, and guardian stops counting them when the window runs out. To apply the notice without waiting, run it by hand in guardian’s container, with the ids from identity:docker compose exec guardian python manage.py revoke_team_membership \ --team <team UUID> --user <user UUID> # an account that was deleted: docker compose exec guardian python manage.py revoke_team_membership \ --user <user UUID> --all-teams--dry-runreports what would be cleared and changes nothing. Use it only for a user identity has removed: the roles it clears do not come back. - Deactivating an account sends no notice: the account keeps its memberships and can be reactivated. It cannot make requests, so it stops counting in guardian when the window runs out.
- After the upgrade, an existing membership counts from the day guardian first saw the user, not from the upgrade. A member who has used guardian since is unaffected; one first seen more than 30 days ago is counted again from their next request.
Notification recipients
guardian e-mails a notification about a team’s data to people of that team,
and to nobody else. There is no platform-wide recipient: the
DEFAULT_NOTIFICATION_RECIPIENTS and SECURITY_TEAM_EMAIL settings that
earlier versions looked for are not read, and defining them changes
nothing. One address for the whole platform would receive every team’s asset
names, vulnerability titles and findings.
| Notification | Sent to | Otherwise |
|---|---|---|
| Alert rule | the rule’s notification_config.recipients |
the owners and admins of the rule’s team |
| Scheduled report | the schedule’s recipients |
the owners and admins of the schedule’s team |
| SLA violation | the vulnerability’s assignee, once a day | the owners and admins of the vulnerability’s team, once |
| Vulnerability assignment | the assignee | nobody: it is meaningful to the assignee only |
| Compliance (high-risk finding, assessment started, completed or overdue, exception expiring) | the owners and admins of the team the assessment or the exception belongs to |
- guardian keeps no e-mail address. It mirrors the identity service’s users
by id, and
guardian-workerasks identity for the addresses when it is about to send: the address of an assignee, or the owners and admins of a team. An address that was changed, a role that was taken away, an account that was deactivated or a member who was removed is therefore not written to from the next e-mail on, and an owner who never opened guardian is told like any other. - An assignee is told while guardian still counts them as a member of the vulnerability’s team (see Team memberships) and identity says they have an active account in it. Otherwise an SLA violation goes to the owners and admins.
- The addresses a team types into an alert rule or a report schedule are used as they are, and stay until the team edits them.
- A row written before guardian kept a team has no owners and admins: only an alert rule’s or a schedule’s own recipients are told about it.
A notification is sent when the mail server accepted it. Otherwise it is recorded as not sent, with the reason, where its team can read it:
| Notification | Where it is recorded |
|---|---|
| Alert rule | GET /api/v1/guardian/reports/alerts/{id}/notifications/: recipients (who it was addressed to), delivered and failure_reason |
| SLA violation | the vulnerability’s history (GET /api/v1/guardian/vulnerabilities/{id}/history/): SLA violation notification sent, sent to the team's owners and admins (no assignee to e-mail) or not sent (<reason>) |
| Vulnerability assignment | the vulnerability’s history: Assignment notification sent or not sent (<reason>) |
| Scheduled report, compliance | the log of guardian-worker: Notification not sent (<kind>), <reason>: <subject> |
| Reason | What to do |
|---|---|
no mail server is configured |
set GUARDIAN_EMAIL_HOST (below) |
guardian is not set up to ask identity for addresses (GUARDIAN_CONTACTS_SECRET) |
set GUARDIAN_CONTACTS_SECRET (below) |
identity could not be asked for the addresses |
identity was not reachable, or refused: the two containers do not have the same GUARDIAN_CONTACTS_SECRET, or identity has none. guardian-worker logs the status it got. An SLA violation is tried again a day later, an assignment three more times |
the team has no owner or admin with an active account and an address |
give the team an owner or an admin in identity |
the member has no active account with an address in the team |
the assignee was deactivated or removed, or left the team: assign the vulnerability to somebody else |
the row belongs to no team, so there is nobody to tell |
assign the rows written before guardian kept a team to one (manage.py assign_guardian_team) |
delivery failed |
the mail server refused or did not answer; guardian-worker logs the kind of error. An SLA violation is tried again a day later |
A reason only a person can remove is recorded once per SLA violation, not once a day. No address is written to a log.
guardian-worker sends guardian’s e-mail, by SMTP. Set these in .env and
recreate the worker (docker compose up -d guardian-worker); every value is
checked when guardian starts, and one that cannot work stops the worker with
the variable’s name in the error.
| Variable | Default | Meaning |
|---|---|---|
GUARDIAN_EMAIL_HOST |
empty | The mail server. Empty means there is none: no e-mail is sent, and every notification is recorded as not sent. Nothing is printed to the log in its place |
GUARDIAN_EMAIL_PORT |
587 |
Its port |
GUARDIAN_EMAIL_USE_TLS |
true |
Switch the connection to TLS once connected, the usual choice on port 587 |
GUARDIAN_EMAIL_USE_SSL |
false |
TLS from the start of the connection, the usual choice on port 465. One of the two, not both; with this one set, GUARDIAN_EMAIL_USE_TLS defaults to false |
GUARDIAN_EMAIL_HOST_USER, GUARDIAN_EMAIL_HOST_PASSWORD |
empty | The login. Both, or neither for a server that takes mail without one. A login without TLS is refused: the password would cross the network in clear text |
GUARDIAN_DEFAULT_FROM_EMAIL |
none | The sender, guardian@example.com or Wildbox Guardian <guardian@example.com>. Required with a mail server: there is no default sender |
GUARDIAN_CONTACTS_SECRET |
empty | What guardian-worker presents to identity to learn who may be e-mailed about a team. The same value goes to identity and guardian-worker, and to nothing else; make generate-secrets writes one, and openssl rand -hex 32 makes one for an existing deployment. It must not be the value of GATEWAY_INTERNAL_SECRET: identity does not start if it is. Empty, guardian e-mails only the addresses typed into an alert rule or a report schedule |
GUARDIAN_TEAM_CONTACTS_URL |
http://open-security-identity:8001/internal/team-contacts |
Where the worker asks. Leave it unset in the Compose stack |
GUARDIAN_BASE_URL |
empty | The address users open the dashboard at, scheme and host only (https://wildbox.example.com). The SLA and assignment e-mails link the dashboard’s vulnerability list under it, and the report e-mail gives the download route in full. Empty, the e-mails carry no link |
EMAIL_BACKENDis not read. Earlier versions defaulted to Django’s console backend, which printed each e-mail to the worker’s log and recorded it as sent.- The worker asks identity on the internal network, not through the gateway,
which does not proxy the route. In the production overlay the two share
the
datanetwork, and the worker reaches the mail server onegress. - The worker holds no
GATEWAY_INTERNAL_SECRET, in either Compose file: it scans addresses and fetches URLs outside the stack. The contacts secret opens one route of identity, which answers the address and role of the active members of a team that the caller names by id or by role. - An alert, a compliance event or a report has no page in the dashboard, so their e-mails carry no link.
cspm’s scan worker
cspm-worker runs the cloud security scans that cspm queues, one at a
time per worker process. It uses cspm’s image and settings and, in the
production overlay, sits on data (Redis) and egress (the cloud provider
APIs). Without it every scan stays queued, and the compliance pages, the
cloud security overview and the reports have nothing to show.
| Variable | Default | Meaning |
|---|---|---|
CSPM_SCAN_TIMEOUT_SECONDS |
3600 |
Time limit of one scan, from 120 to 86400 seconds. The worker is also given this long to stop, so docker compose stop or down can wait this long while a scan runs |
CSPM_REPORT_RETENTION_DAYS |
90 |
Days a scan and its report are kept in Redis; see Redis memory |
Check that it is up and reading its queue:
docker compose -f docker-compose.yml -f docker-compose.prod.yml ps cspm-worker
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec cspm-worker \
sh -c 'celery -A app.worker:celery_app inspect active_queues -d "celery@$HOSTNAME"'
The second command lists one queue, celery. GET /health on cspm also
reports "celery": "healthy" once a worker answers. Until then it answers
200 with "status": "degraded", so the cspm container does not read
unhealthy for want of a worker; it answers 503, unhealthy, when Redis
cannot be reached.
- Capacity. One worker runs two scans at once (
--concurrency=2, one CPU, 1 GB). For more, run more workers,docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --scale cspm-worker=3, or raise the concurrency together with the container’s limits. The cspm README, “The scan worker”, compares the two. - Credentials. The API keeps a scan’s encrypted credentials in Redis for five minutes; a scan that no worker takes within that time fails. The worker deletes them as soon as it has read them.
- Providers. Only AWS can be scanned. A scan for GCP or Azure is
refused with 400 when it is submitted (“Unsupported provider: gcp.
Supported providers: aws.”), before anything is stored or queued, and a
batch that names one is refused whole.
GET /api/v1/cspm/providerslists the providers that can be scanned, with their number of checks.
The responder’s playbooks
A playbook run calls the tools, data, guardian and agents services as the
user who started it: each request carries that user’s gateway identity and
GATEWAY_INTERNAL_SECRET, and the service authorizes it for that user and
team. The playbook worker runs inside the responder container, so the
container’s environment is what it uses.
| Variable | Default | Meaning |
|---|---|---|
RESPONDER_WILDBOX_API_URL |
http://open-security-tools:8000 |
Tools service, passed as WILDBOX_API_URL |
RESPONDER_WILDBOX_DATA_URL |
http://open-security-data:8002 |
Data service, passed as WILDBOX_DATA_URL |
RESPONDER_WILDBOX_GUARDIAN_URL |
http://open-security-guardian:8013 |
Guardian, passed as WILDBOX_GUARDIAN_URL |
RESPONDER_WILDBOX_AGENTS_URL |
http://open-security-agents:8006 |
Agents service, passed as WILDBOX_AGENTS_URL |
The defaults are the services’ addresses in both compose files. In the
production overlay the responder reaches them on backend;
scripts/check_network_segmentation.py runtime checks that it does.
- Permissions follow the user. A step the user may not take fails:
Guardian lets only owners and admins create a vulnerability, so
all_star_e2erecords none when a member runs it. - Results belong to the user. A tool task or an AI analysis a run queues is listed and readable by the user who ran it, and by nobody else.
The agents service’s tools and limits
The AI analysis calls the tools service, and the data and guardian services
when you enable their tools, as the user who submitted it: each request
carries that user’s gateway identity and GATEWAY_INTERNAL_SECRET. Set
these in .env; both compose files pass them to the agents container.
| Variable | Default | Meaning |
|---|---|---|
WILDBOX_API_URL |
http://api:8000 |
Tools service |
AGENT_TEAM_DATA_TOOLS |
empty | The team-data tools the model is given; see below before setting it |
AGENTS_WILDBOX_DATA_URL |
http://open-security-data:8002 |
Data service, passed as WILDBOX_DATA_URL; the threat-indicator search |
AGENTS_WILDBOX_GUARDIAN_URL |
http://open-security-guardian:8013 |
Guardian, passed as WILDBOX_GUARDIAN_URL; the vulnerability search. The host must be in Guardian’s ALLOWED_HOSTS |
ANALYZE_RATE_LIMIT |
5/minute |
Analyses each user may submit, in the limits notation (5/minute;50/day for several) |
ANALYZE_TEAM_RATE_LIMIT |
empty | Optional ceiling for all users of one team together |
A URL that is not an absolute http or https URL, a limit that cannot be
parsed, or an AGENT_TEAM_DATA_TOOLS value that names anything but the two
tools stops the agents service at start. The limit counters are kept in
Redis and survive a restart. In the production overlay the agents service
reaches the three services on backend;
scripts/check_network_segmentation.py runtime checks that it does.
Giving the AI analysis your team’s data
By default an analysis looks the indicator up outside (reputation, WHOIS, DNS, geolocation, redirects, open ports) and reads nothing Wildbox holds. Two more tools exist, and the model is given one only if you name it:
# .env; either name alone, or both
AGENT_TEAM_DATA_TOOLS=threat_intel_query_tool,vulnerability_search_tool
threat_intel_query_toolsearches the data service’s indicators: those of the user’s team and of the feeds shared by every team.vulnerability_search_toolsearches the vulnerabilities Guardian records on the team’s assets.
Both act as the user who submitted the analysis, so they return what that user may see. Decide with these two facts in hand:
- What it sends to Anthropic. Every tool output is part of the conversation with the model. For each search the model makes, and it chooses the search text and may search several times: up to 25 indicators (type, value, threat types, confidence, severity, description, tags, dates), or up to 25 vulnerabilities (title, CVE ID, severity, status, priority, scores, asset name and type, due date), and the number of matches.
- The injection risk. That data then sits in the model’s context beside
text the other tools fetched from the internet: WHOIS records, DNS
answers, and the redirects and headers of the URL being analyzed. The
model also holds tools that reach outside, with arguments it writes
(
url_analysis_tool,dns_lookup_tool,whois_lookup_tool). Someone who controls the fetched text can write it as instructions, asking the model to search your vulnerabilities and pass the result out in one of those arguments. The prompt tells the model to treat tool output as data and not to do this; that is a request to the model, not a control.
With the setting empty, neither tool is in the model’s tool list or its prompt, and the agents service makes no request to the data service or to Guardian.
Internal targets of the network tools
The network tools (port and vulnerability scanners, the TLS and
certificate analyzers, network_scanner, iot_security_scanner,
database_security_analyzer, dns_enumerator, and the image registry of
container_security_scanner) refuse internal targets: private, loopback,
link-local, multicast, reserved and shared addresses, ranges that contain
one, names that resolve to one, and the stack’s own service names. A
range holds at most 1024 addresses. A run that is refused answers 400
with the reason, synchronous or asynchronous: the asynchronous submission
applies the policy before it queues anything. The worker applies it again
when the task runs, since a name can resolve to another address by then; a
task refused at that point ends with status failed and the reason in
error.
To scan an internal lab, list its ranges and hosts in .env, then
recreate api and tools-worker:
TOOLS_ALLOWED_INTERNAL_TARGETS=10.20.0.0/16,192.168.50.0/24,lab-dc01
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d api tools-worker
- Entries. CIDR ranges with their host bits zero, IP addresses and
host names, comma-separated. A name is matched exactly. A bad entry
stops both containers at start-up;
docker compose logs apinames it. - An IPv4 entry covers IPv4 addresses only. The same hosts written as
IPv4-mapped IPv6 addresses are not covered: with
10.20.0.0/16listed, the target::ffff:10.20.3.4is still refused. To allow that spelling too, list the mapped range as well,::ffff:10.20.0.0/112(the IPv6 prefix length is 96 plus the IPv4 one). A mapped entry does not cover the IPv4 spelling either. - Keep the stack out. Every caller of every network tool can scan
what is listed. Do not list the stack’s Docker networks (by default in
172.16.0.0/12); its service names stay refused unless listed by name.
The tools README, “Network targets”, lists the fields checked per tool.
Internal targets of Guardian’s scans
Guardian’s asset discovery and port scans connect from guardian-worker,
which is on the stack’s networks: with every service in docker-compose.yml,
and on data (PostgreSQL, Redis, identity) and egress in the production
overlay. They refuse internal targets, by the same policy as the network
tools: private, loopback, link-local, multicast, reserved, shared and
cloud-metadata addresses, IPv4 and IPv6, and any network that contains one,
even in part. A discovery, a discovery rule or a port scan aimed at one
answers 400 with the reason, and nothing is queued; the worker checks again
when the task runs. Guardian scans addresses only, so there is no host name
to check. An asset at an internal address is still recorded in the
inventory; it is not port scanned.
Nothing internal is scanned by default, your own LAN included. To scan it,
list its ranges in .env, then recreate guardian and guardian-worker:
GUARDIAN_ALLOWED_INTERNAL_TARGETS=192.168.50.0/24,10.20.0.0/16
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d guardian guardian-worker
- Entries. CIDR ranges with their host bits zero and IP addresses,
comma-separated. No host names. A bad entry stops both containers at
start-up;
docker compose logs guardiannames the variable and the entry. - A network must be inside the list. A discovery of
192.168.50.0/23is refused when only192.168.50.0/24is listed. The limit of 1,024 addresses for each discovery applies to listed ranges too. - An IPv4 entry covers IPv4 addresses only. The same hosts written as
IPv4-mapped IPv6 addresses are not covered: with
10.20.0.0/16listed, a discovery of::ffff:10.20.3.0/120is still refused. To allow that spelling too, list the mapped range as well,::ffff:10.20.0.0/112(the IPv6 prefix length is 96 plus the IPv4 one). A mapped entry does not cover the IPv4 spelling either. - IPv6 targets need an IPv6 route. A port scan connects over IPv6 to an
IPv6 asset (since 0.12.1), from
guardian-worker. The stack’s Docker networks are IPv4 only, as Docker creates them unless told otherwise: from such a worker a public IPv6 address is unreachable, and its scan completes with no open port. Give the worker’s network IPv6 if you scan IPv6 assets. - Guardian’s list, not the tools service’s.
TOOLS_ALLOWED_INTERNAL_TARGETSopens nothing for Guardian, and this variable nothing for the tools. Set both if both scan the lab. - Keep the stack out. Every owner and admin of every team can scan what
is listed. Do not list the stack’s Docker networks (by default in
172.16.0.0/12), loopback or169.254.169.254. - Rules and assets stored before the upgrade keep their networks and
addresses. A rule’s run skips each internal network that is not listed.
The rule’s record says so, in
last_run_result: each skipped network with the reason, which names the variable, and statusskippedwhen no network was left to scan (since 0.12.1;guardian-workerlogs the same). The rule cannot be saved again with that network until it is listed.
The sensor’s telemetry
The sensor service sends host telemetry to the gateway,
https://open-security-gateway/api/v1/data/ingest, authenticated with an
identity personal API key; the data service stores it under the key’s team.
It trusts the gateway’s certificate, which the gateway copies (never its key)
into the gateway_cert volume at every start, so a certificate you replace
under open-security-gateway/ssl/ reaches the sensor at the next restart of
both. In the production overlay the sensor is on frontend with the gateway:
it reaches no backend service directly.
It forwards nothing until it has a key:
- As a team owner or admin, add a member for the sensor (Settings > Team > Add member) in the team the telemetry belongs to, and sign in as it once to change its password.
- As that member, create a personal API key with the Telemetry Ingest
(
data:ingest) scope only (Settings > API keys). -
Put it in
.envand restart the sensor:SENSOR_DATA_LAKE_API_KEY=wsk_... docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d sensor docker compose -f docker-compose.yml -f docker-compose.prod.yml exec sensor \ python main.py --config /etc/security-sensor/config.yaml --test-connection
| Variable | Default | Meaning |
|---|---|---|
SENSOR_DATA_LAKE_API_KEY |
empty | The sensor’s key. Empty: the sensor runs and forwards nothing |
SENSOR_DATA_LAKE_ENDPOINT |
https://open-security-gateway |
The gateway’s HTTPS URL as the sensor reaches it |
SENSOR_DATA_LAKE_CA_BUNDLE |
/etc/ssl/wildbox/wildbox.crt |
The certificate the sensor trusts for the gateway. Set it to an empty value to use the system trust store instead |
SENSOR_DATA_LAKE_SENSOR_ID |
open-security-sensor |
The sensor’s name in the data service, unique within the team |
Revoking the key, or removing the member from the team, stops the sensor’s telemetry at its next batch. Sensors on other hosts are set up the same way; see the sensor README, “Sending telemetry to Wildbox”.
5. Verify
curl -s http://localhost/health # gateway
curl -sI https://wildbox.example.com/ | head -20 # TLS and security headers
Then log in through the gateway with the sequence in the
Quick Start, using your host name
and dropping --cacert once the certificate is publicly trusted. The health
loop on the Service ports page checks every
backend from the server itself.
6. Backups and Restore
make backup # PostgreSQL and Redis, into ./backups
make restore-drill # prove the PostgreSQL backup restores
Both work on the default stack with nothing but Docker on the host. They run
pg_dump, pg_restore and psql, and the backup redis-cli, inside the
stack’s own containers with docker compose exec (Redis is not part of the
drill), so the database port stays
unpublished, no client tools are installed, and no password is passed on a
command line. Set COMPOSE_FILE (and COMPOSE_PROJECT_NAME, if you use one)
the way you start the stack; ENV_FILE names the env file when it is not
.env.
What a backup contains
scripts/backup_postgres.sh writes one set of files per run, named by the
run’s timestamp:
| File | Contents |
|---|---|
identity_<stamp>.sql.gz, data_<stamp>.sql.gz, guardian_<stamp>.sql.gz |
A pg_dump custom-format archive of each database. |
redis_<stamp>.rdb.gz |
An RDB snapshot of every Redis database. |
Redis is in the backup because it is not a cache here. It holds the only copy of CSPM scan metadata and reports, responder playbook run state, agents analysis results, tools task ownership, identity’s revoked-token list and account lockouts, and the Celery queues.
A run either completes or keeps nothing: if any database or Redis fails, the
script exits non-zero and removes the files it wrote, so every timestamp in
the directory is a complete set. Leaving Redis out is something you ask for
with SKIP_REDIS=true, and the output says so. Files are written with mode
600 in a mode 700 directory, since they hold every password hash and
stored credential. Archives older than BACKUP_RETENTION days (30 by
default) are removed after a successful run.
The guardian archive holds no credential of a scanner or an external system,
because guardian stores none
(#728). An archive
written by 0.11.2 or earlier can hold them in plain text, if a team had sent
any: a scanner’s API key or password, an external system’s auth_config, a
webhook endpoint’s secret_token, a notification channel’s config.
Upgrading removes them from the database, not from the archives already
written, and restoring one of those archives into the current version removes
them from the restored database only. Delete those archives as soon as you
can do without them, and if one may have been read, change the secrets it
holds where they were issued.
| Variable | Default | Meaning |
|---|---|---|
BACKUP_DIR |
./backups (/backups/postgres in host mode) |
Where the files go. |
BACKUP_RETENTION |
30 |
Days of archives to keep. |
DATABASES |
identity,data,guardian |
Databases to dump; also --databases. |
SKIP_REDIS |
unset | true leaves Redis out. |
GPG_RECIPIENT |
unset | Encrypts every file for this recipient. |
S3_BUCKET |
unset | Target of --upload-s3. |
Copy the archives off the server: a backup on the same disk is not a backup.
Scheduled backups
The backup Compose profile runs the same script in a container on the
Compose network every BACKUP_INTERVAL_SECONDS (one day by default) and
keeps the archives in the wildbox_backups volume:
docker compose --profile backup up -d backup
docker compose logs backup
A failed run logs [backup] FAILED and keeps nothing; alert on it.
An external database
For a PostgreSQL the stack does not run (a managed database, another host),
use host mode: the script connects from the machine it runs on. It needs
pg_dump, pg_restore and psql of the server’s major version on PATH,
and redis-cli unless SKIP_REDIS=true.
BACKUP_MODE=host POSTGRES_HOST=db.internal POSTGRES_USER=postgres \
POSTGRES_PASSWORD=... REDIS_HOST=redis.internal REDIS_PASSWORD=... \
BACKUP_DIR=/var/backups/wildbox ./scripts/backup_postgres.sh
Setting POSTGRES_HOST selects host mode by itself. The passwords are read
from the environment and handed to the client tools through their
environment; no password file is written.
Restoring
make restore-drill (scripts/verify_restore.sh) takes a backup, restores
it into scratch databases named <db>_restore_drill, compares every table’s
row count with the source, and drops the scratch databases. The live
databases are only read. Run it on a schedule: a restore that has never been
tested is not one.
The comparison is exact, also on a stack that is in use. For each database
the drill opens one read-only transaction, counts every table inside it, and
has the backup dumped from that transaction’s snapshot
(backup_postgres.sh --snapshot). The restored copy has to hold the same
tables with the same row counts, to the row; what the services write while
the drill runs is on neither side of the comparison. The drill fails if a
count differs, if it cannot open a snapshot, or if the backup cannot be taken
from it.
To restore for real, stop the services first, then:
docker compose stop
docker compose start postgres
./scripts/restore_postgres.sh --timestamp 20261005_120000 --overwrite-live-databases # or --latest
./scripts/restore_redis.sh --timestamp 20261005_120000 --replace-redis-data
docker compose up -d
restore_postgres.sh --overwrite-live-databases leaves each database with
what its archive holds, and with no other table, view, materialized view or
sequence in the schemas the archive holds. One made after the backup, by the
migrations of a later release for instance, is dropped in the transaction of
the restore together with what depends on it, and the script lists what it
removed. That is what going back to an earlier release needs: the release
that made a table can migrate again afterwards. Up to 0.12.0 such a table
was left in place, and the next upgrade stopped on relation ... already
exists
(#773); the
databases no longer have to be dropped and created before a restore.
The restore leaves alone what an extension owns, what is not a relation (a
function or a type made after the backup stays), and every schema the
archive does not hold, such as one you made after the backup: it drops no
table, view or sequence there. If one of them is in the way (a view on a
restored table or on a table the restore has to remove, a foreign key to a
restored table), the restore of that database fails, names it and changes
nothing: drop it, or its schema, and run the same command again. What only
refers from such a schema to a table or a sequence made after the backup, a
foreign key or a column default, goes with it, and PostgreSQL’s notices say
so. A database restored with --into-suffix that is already there keeps
what the archive does not hold.
The script works in a directory it makes under TMPDIR (/tmp by default)
and removes when it ends. It holds every archive without its outer gzip,
which is about the size of the backup files, and the SQL of one database at
a time, gzipped, which is about the size of that database’s archive again.
Set TMPDIR to a disk with room for the backup files plus the largest of
them.
Both restores destroy everything written since the backup, so each runs only
with its flag. --overwrite-live-databases restores over the databases the
services use, and --replace-redis-data replaces the Redis data volume.
Without the flag the script changes nothing, exits with status 2, and says
what it would have overwritten: each database with the archive it would be
restored from, or the Redis volume with the snapshot. Neither script asks a
question, so both still run from a script of your own.
Give both scripts the same --timestamp, so that PostgreSQL and Redis come
from the same backup run. --latest means the newest run: the files that
carry the newest timestamp, read from their names. restore_postgres.sh
--latest refuses when that run does not hold every database asked for (a
run taken with --databases), and restore_redis.sh --latest refuses when
it holds no Redis snapshot (a run taken with SKIP_REDIS=true): the newest
archive of each would be data from different moments. The refusal names the
newest run that is complete. To mix runs on purpose, run
restore_postgres.sh once per database with --databases and
--timestamp.
restore_postgres.sh has two targets that need no flag: --into-suffix
_check restores into <db>_check next to the live databases, and
--dry-run only reads the archives. It looks up every archive before it
touches a database, so a missing one stops the run with nothing restored.
restore_redis.sh also refuses to run while Redis is running. It exists
because Redis runs with the append-only file enabled and then ignores a
dump.rdb at start: copying the snapshot into the volume by hand gives an
empty Redis.
When a restore fails
A restore that fails leaves the data that was there.
restore_postgres.sh reads every archive before it touches a database, and
restores each database in one transaction. pg_restore writes the archive
as SQL to a file, and psql is given one stream: BEGIN, the removal of
what was made after the backup, that SQL, COMMIT. The file is used only if
pg_restore succeeded and it ends where a complete dump ends; a stream that
ends early for any other reason has no COMMIT, so it commits nothing.
If anything in it fails, PostgreSQL rolls that database back to what it was.
The three databases are three transactions, because PostgreSQL has none that
spans databases: if the second fails, the first is restored and the other two
are unchanged. The script lists which is which, and the same command run
again restores all three; restoring a database twice is safe.
restore_redis.sh loads the snapshot in a scratch directory of the Redis
volume. The temporary server has to answer and to hold as many keys as the
snapshot, the append-only file it writes is read back, and only then is it
swapped with the data in place. The volume needs room for both copies during
the restore. The swap itself is two renames: if the script is killed between
them, the previous data is in .restore-previous in the volume, and the next
run puts it back before it does anything else.
7. Monitoring
The monitoring profile starts two services:
docker compose --profile monitoring up -d
With the production overlay, give every docker compose command in this
section the same -f docker-compose.yml -f docker-compose.prod.yml you
started the stack with; without them Compose reads docker-compose.yml
alone, and an up -d that names no service recreates the whole stack from
it, on the development network.
| Service | Address | What it does |
|---|---|---|
prometheus |
127.0.0.1:9090 |
Scrapes the services (monitoring/prometheus.yml), evaluates the alert rules (monitoring/alert_rules.yml) and sends what fires to Alertmanager |
alertmanager |
127.0.0.1:9093 |
Groups the alerts it receives, shows them, lets you silence them, and notifies the receiver its configuration names |
Neither has authentication, so both are bound to localhost; reach them from
another machine through an SSH tunnel
(ssh -L 9090:127.0.0.1:9090 -L 9093:127.0.0.1:9093 <host>). There is no
Grafana in docker-compose.yml; connect your own if you want dashboards.
Out of the box, nobody is notified
The configuration that ships, monitoring/alertmanager.yml, sends every alert
to a receiver named no-notifications, which has no integration. A firing
alert is then visible in two places and nowhere else:
- the Alertmanager UI,
http://127.0.0.1:9093; - the Prometheus alerts page,
http://127.0.0.1:9090/alerts.
No e-mail, webhook or chat message is sent until you configure a receiver. This is deliberate: the repository cannot know your mail server, and a default that tried one would fail every notification instead of saying that none is configured.
The alerts
| Alert | Fires when | What it does not see |
|---|---|---|
WildboxServiceDown |
Prometheus cannot scrape /metrics on identity, tools, data, responder, CSPM or agents for 2 minutes |
guardian, the gateway, the dashboard, the workers, PostgreSQL and Redis are not scraped |
WildboxHighErrorRate |
more than 5% of the HTTP requests one of those services handled ended in a 5xx, for 10 minutes | requests the gateway refused or could not forward: each service counts its own |
WildboxSyncToolFailureRate |
more than 25% of the synchronous tool runs (POST /api/v1/tools/{tool}) raised an error the tool does not handle, for 15 minutes |
asynchronous runs (.../async), which the next alert measures. Timeouts, refused runs and a failure the tool reports in its result (success: false) are not counted as failures |
WildboxAsyncToolFailureRate |
more than 25% of the asynchronous tool runs (POST /api/v1/tools/{tool}/async) failed, for 15 minutes: the tool raised an error it does not handle, or the task failed in the worker after its retries |
synchronous runs. Timeouts (a task killed at the hard time limit included), canceled tasks, tasks that ended before the tool started (a caller the tool refuses, a target refused when the task runs) and a failure the tool reports in its result are not counted as failures. A count the worker could not write to Redis is lost |
WildboxAsyncToolTasksNotConsumed |
asynchronous tool tasks have been in the queue for 15 minutes and no worker took any task in that time: tools-worker is stopped, restarting or cannot reach Redis |
a backlog that a busy worker is working through, and tasks a worker had already taken when it was killed whole (docker kill, the kernel’s out-of-memory killer on the container): they are not in the queue, they read running, and a running worker puts them back only once they were taken longer ago than the broker’s visibility timeout, 3600 seconds (broker_transport_options in open-security-tools/app/celery_app.py); it looks when it starts and every hundred seconds after that |
WildboxAsyncToolMetricsUnreadable |
the tools API has not been able to read the asynchronous counters from Redis for 10 minutes | nothing else: while it fires, the two alerts above cannot |
WildboxAlertmanagerDown |
Prometheus cannot scrape Alertmanager for 5 minutes | it cannot be delivered: it is shown on the Prometheus alerts page only |
WildboxAlertNotificationsFailing |
Alertmanager failed to send a notification in the last 15 minutes | if the failing receiver is the only one it cannot be delivered either: it is shown in both UIs |
tools-worker, which executes the asynchronous runs, is not scraped: it
serves no HTTP, and on the production networks Prometheus cannot reach it.
It counts in Redis how each task ended, and the tools API, which is
scraped, exports the counts (wildbox_tool_async_executions_total), the
length of the task queue (wildbox_tool_async_queue_length) and how many
tasks the worker has taken (wildbox_tool_async_tasks_consumed_total).
The counts live as long as the Redis data, so a restart of the API or the
worker does not reset them.
There is no alert on the threat-feed collection, on scans or on backups: none of them exports a metric Prometheus can read.
Being notified
Alertmanager does not read environment variables, and a password on its command line would be visible to anyone who can list processes. A receiver is therefore configured with two things you mount: a configuration file, which holds no secret, and a directory of secret files that the configuration names.
Variable in .env |
Default | What it is |
|---|---|---|
ALERTMANAGER_CONFIG_FILE |
./monitoring/alertmanager.yml |
The configuration file, mounted read-only |
ALERTMANAGER_SECRETS_DIR |
./monitoring/secrets |
Mounted read-only at /etc/alertmanager/secrets. Git ignores everything in the default directory |
ALERTMANAGER_EXTERNAL_URL |
http://127.0.0.1:9093 |
The address notifications link to. Not a secret |
PROMETHEUS_EXTERNAL_URL |
http://127.0.0.1:9090 |
The address an alert’s “source” link points to. Not a secret |
Two examples are provided; CI validates both with amtool check-config.
E-mail. Copy the example, replace the example.com values (mail server,
sender, user name, recipient), and put the SMTP password in a file:
mkdir -p monitoring/local
cp monitoring/examples/alertmanager-email.yml monitoring/local/alertmanager.yml
$EDITOR monitoring/local/alertmanager.yml
touch monitoring/secrets/smtp_password
chmod 600 monitoring/secrets/smtp_password
$EDITOR monitoring/secrets/smtp_password # the password, one line
sudo chown 65534 monitoring/secrets/smtp_password # Linux hosts
Generic webhook. Alertmanager sends an HTTP POST with a JSON body to a URL of yours (format). The URL usually carries a token, so it goes in a file too; the example needs no other change:
mkdir -p monitoring/local
cp monitoring/examples/alertmanager-webhook.yml monitoring/local/alertmanager.yml
touch monitoring/secrets/webhook_url
chmod 600 monitoring/secrets/webhook_url
$EDITOR monitoring/secrets/webhook_url # the URL, one line
sudo chown 65534 monitoring/secrets/webhook_url # Linux hosts
The example also shows, commented out, how to send a bearer token read from
monitoring/secrets/webhook_token.
Alertmanager runs as UID 65534 (nobody) and reads a secret file each time it
notifies, which is why the file is given to that user on a Linux host. Docker
Desktop on macOS presents a mounted file as owned by the container’s user, so
the chown is not needed there. monitoring/local/ and monitoring/secrets/
are ignored by Git.
Then, for either example, set the configuration file in .env, check it and
recreate the container (also after every later change to the file):
echo 'ALERTMANAGER_CONFIG_FILE=./monitoring/local/alertmanager.yml' >> .env
docker compose --profile monitoring run --rm --no-deps --entrypoint amtool \
alertmanager check-config /etc/alertmanager/alertmanager.yml
docker compose --profile monitoring up -d --force-recreate alertmanager
amtool check-config checks the syntax only: it does not open the secret
files or contact the mail server. Send a test alert to see a notification
arrive (after group_wait, 30 seconds in the examples):
docker compose --profile monitoring exec alertmanager amtool alert add \
WildboxTestNotification severity=info \
'--annotation=summary="Test notification sent by hand"' \
--alertmanager.url=http://127.0.0.1:9093
If nothing arrives, docker compose logs alertmanager gives the reason at
once. The usual causes are a secret file that UID 65534 cannot read, a wrong
password, and a server the container cannot reach.
WildboxAlertNotificationsFailing follows, but not at once: Alertmanager
counts a notification as failed when it stops retrying, which is immediately
for a secret file it cannot read and after group_interval (5 minutes in the
examples) for a wrong password or an unreachable server, and the alert fires
5 to 6 minutes after that: about 11 minutes after the first failed attempt in
the second case. An invalid configuration file keeps Alertmanager restarting;
WildboxAlertmanagerDown then fires in Prometheus.
In the production overlay Alertmanager is on the backend network with
Prometheus. That network has a route out, so a mail server or webhook outside
the stack is reachable; PostgreSQL and Redis are not.
To use an Alertmanager you already run instead, change the alerting: target
in monitoring/prometheus.yml to its address.
Logs
The production overlay rotates the container log of every service, Prometheus
and Alertmanager included: files of 10 MB, five kept for the application
services and three for PostgreSQL, Redis, the dashboard, Flower, the backup
loop, Prometheus and Alertmanager. Read them with
docker compose logs <service>. Without the overlay (docker compose up on
docker-compose.yml alone) Docker’s defaults apply and nothing is rotated.
8. Updating
Read the notes for your target version in UPGRADING.md before pulling; they list what an existing deployment has to do.
git pull
make validate-secrets # new releases can add required secrets
docker compose -f docker-compose.yml -f docker-compose.prod.yml build
make start-prod
The images are built from the repository, and make start-prod does not
rebuild an image that already exists: build first, or the services keep
running the previous release. The same applies to the dashboard’s
NEXT_PUBLIC_* settings, which take effect only in a rebuilt image.
Troubleshooting
- A container keeps restarting:
docker compose logs <service>; a missing secret is reported by name. - Clients get certificate errors: check that
wildbox.crtcontains the full chain and matches your host name. - Requests on port 80 return 301: expected; only
/healthis served over HTTP. - PostgreSQL:
docker compose exec postgres psql -U postgres -c 'SELECT 1'.
Support
- Security policy and Security status
- GitHub Issues
- Security reports: see SECURITY.md