Wildbox Security Status
What is known about Wildbox’s security today, including what is still wrong. This page replaces an earlier report that marked every audit finding “Fixed” and every check “PASS”; several of those claims did not hold when they were checked against the code.
Current release: v0.12.2. Its changes, and those of every earlier
release, are listed in CHANGELOG.md.
Checked against: main on 4 October 2026. Each check below names how it
was made, so it can be repeated.
To report a vulnerability, follow SECURITY.md; do not open a public issue.
Known Open Issues
| Issue | Where | Notes |
|---|---|---|
| Advisory without a fix in the dashboard’s build tooling | dashboard | npm audit reports GHSA-vfj7-8cjw-p6xm (high, published 18 September 2026, no patched release) in braces, reached only through eslint-config-next since the move to Tailwind CSS 4 in v0.11.1. It is lint tooling and does not reach the built image. No issue is open for it yet. |
Verification Checks
Re-run on main on 4 October 2026. “Not verified” means nobody has checked
the claim against the current code; it is not a pass.
| Check | Result | How it was checked |
|---|---|---|
No eval() in service code |
PASS | grep for eval( in open-security-*/**/*.py, outside tests: only string literals in blocklists |
| No plaintext passwords in code or logs | PASS | The initial administrator password is no longer printed (#493). No password literal is used as a default: docker-compose.yml refuses to start without the required secrets |
| No request content or credential in a log line | PASS | A log line says what a request was (the route, the caller, the names of its fields) and never what it held (#755). The tools service logs a run with the tool, the caller’s user and team ids, the request id and the names of the fields set, and writes only the record fields named in LOGGED_FIELDS; a task’s message describes its arguments without the input. The access logs of the gateway and of the six FastAPI services have the path of a request and not its query string, and the HTTP and cloud client libraries do not log the requests they send. tests/scripts/test_no_request_values_in_logs.py reads every logging call of every service, and the gateway’s log formats and Lua, and fails when one is handed a value whose name says it is a request, a part of one or a credential; each service has tests that submit a marker and read the captured records, and the gateway’s test/access_log_tests.sh reads what both gateway images wrote. An error no route handles is logged by the six FastAPI services with its class, the place it was raised at and its frames, and without its text, in the shared handler’s record and in the one uvicorn writes for the same error (#788). Not covered: a log call of a service’s own that writes the text of an exception it caught, the tracebacks guardian and the Celery workers log, and nginx’s error log, which ends each line it writes while handling a request with that request’s first line |
| No known vulnerable dependencies | FAIL, one advisory without a fix | Re-checked on 6 October 2026, when GitHub opened eleven Dependabot alerts: multidict in the locks of cspm, data, guardian, sensor and tools, Mako in data, seroval (one critical, one high) and source-map-js in the dashboard. The locks now hold releases without those advisories: multidict 6.9.1, Mako 1.4.3, source-map-js 1.2.2 and seroval 1.6.8, the last through an overrides entry in the dashboard’s package.json because solid-js 1.9 accepts seroval 1.5 only. GitHub closes an alert when it scans the lock that fixes it. pip-audit (--no-deps --disable-pip, OSV database) finds no advisory in the hash-pinned locks of the eight Python services (#415) or in the CI tools lock. npm audit finds one advisory in the dashboard’s build tooling; see “Advisory without a fix in the dashboard’s build tooling” above. It did not list the three npm advisories of that day, before or after the fix, so the dashboard’s versions were also checked against the OSV database (api.osv.dev), which lists them for the old versions and nothing for the new ones. The Main Advisories workflow re-checks main daily with Trivy, for critical advisories that have a released fix |
No .env file in git |
PASS | git ls-files finds only .env.example and .env.template files, which hold placeholders |
| Database and Redis not published to the host | PASS | Neither postgres nor wildbox-redis has ports: in docker-compose.yml; backends bind to 127.0.0.1 |
| Docker networks segmented | PASS | In the production configuration (docker-compose.yml + docker-compose.prod.yml, Compose 2.24.4+) each service’s networks are replaced with !override rather than merged with the flat wildbox network (#494). The Production Stack workflow checks the rendered map service by service, starts the stack, runs the integration suite against it, and probes from inside the containers with scripts/check_network_segmentation.py: the dashboard cannot resolve PostgreSQL, Redis or any backend service, the gateway cannot resolve PostgreSQL or Redis, the data network has no route out, and every connection a service needs succeeds. The development stack (docker-compose.yml alone) stays on one flat network |
| Gateway requires authentication on service APIs | PASS | Every /api/v1/<service>/ location in wildbox_gateway.conf calls auth_handler.authenticate(); identity validates tokens itself |
| Every gateway route requires the API-key scope it documents | PASS | ROUTE_SCOPES in auth_handler.lua holds one row for each authenticated route, matched by the path nginx chose the location for; a path with no row requires admin. open-security-gateway/test/route_scope_tests.sh runs the production image and configuration, reads every location that calls authenticate() and fails for one without a pin, and checks each pin per method: a key with no scope is refused and told the pinned scope, and a key holding exactly that scope reaches the upstream path. GET /api/v1/tools needs tools:read, and a location that rewrites its path is mapped by the path it was chosen for (#647) |
| API-key scopes are checked by the gateway and again by data, guardian and tools | PASS | The gateway forwards what it decided on: X-Wildbox-Auth-Type (session or api_key) and, for an API key, X-Wildbox-Scopes. authenticate() sets both and removes a client’s own, and a location that does not authenticate forwards neither (auth_handler.lua, proxy_params.conf). The data service requires data:ingest on its ingest route and read or write on every other one, so a sensor’s key does nothing else there; guardian requires data:read, data:write or data:delete by method in its middleware; tools requires tools:execute to run a tool or cancel a task (open_security_shared.scopes, gateway_auth.require_scope). All three refuse a request that does not state its credential, and a header they cannot read. The gateway’s check stands alone for reading tools and tasks and for the agents, responder and CSPM services; and a call one service makes for a user states service, which is not limited by the scopes of the key that started the work (#637). Tested in tests/shared/test_scopes.py and test_gateway_scopes.py, in tests/unit/test_api_key_scopes.py of data, guardian and tools, in open-security-gateway/test/scope_forwarding_tests.sh (the forwarded value is the key’s scopes; a client’s headers are dropped) and scope_vector_tests.sh (the gateway and the services decide alike, on 200 pairs), in route_scope_tests.sh (every route of the production configuration tells the service the key’s scopes), and through the gateway in tests/integration/test_api_key_scopes_backends.py |
| Gateway secret sent only for authenticated callers | PASS | proxy_params.conf sends X-Gateway-Secret from $wildbox_gateway_secret, which the server block seeds empty and only authenticate() fills in (auth_handler.lua). The identity passthrough and the dashboard forward no secret and drop a client’s own (#664). Tested in open-security-gateway/test/ci_auth_tests.sh and tests/integration/test_identity_access_scope.py |
| Gateway secret and caller credentials sent only to the services that validate them | PASS | n8n, behind /api/v1/automations/, is not a Wildbox service and used to be sent X-Gateway-Secret, the caller’s user, team and role and, from a browser, the session JWT in the auth_token cookie. The gateway no longer routes to n8n at all (next row). The auth_token cookie is removed on every authenticated route, and a client’s X-API-Key no longer reaches the dashboard or identity’s own routes (#711). open-security-gateway/test/upstream_header_tests.sh runs the production image and configuration, classifies every location that proxies (it fails for one it does not know) and checks what each upstream receives of a request carrying a session token, an API key, the session cookie and forged gateway headers. tests/scripts/test_gateway_authenticated_locations.py fails for a location whose upstream is not a Wildbox service or the dashboard |
| n8n is not reachable through the gateway | PASS | /api/v1/automations/ proxied to n8n’s editor, REST API and webhooks for every registered session of every team, and on an instance with no owner account yet the first caller could create it. The location, its scope-map row and the third_party upstream kind are removed: the path answers 404 with or without a credential, which upstream_header_tests.sh and route_scope_tests.sh check against the production image for a session, a tools:admin key, an admin key and an unlimited key. n8n’s editor is published on 127.0.0.1:5678 only (tests/scripts/test_automations_exposure.py, scripts/check_container_hygiene.py), and the N8N_BASIC_AUTH_* variables, which n8n 1.x ignores, are no longer set, generated or required. The operator creates n8n’s owner account right after the first start; until then whoever reaches port 5678 can (#714) |
| Identity admin metrics restricted to superusers | PASS | GET /api/v1/admin/metrics authenticates the bearer token and requires is_superuser; the gateway secret alone no longer opens it (#664). Tested anonymously, with a forged and with the real secret, as a team owner and as a superuser in open-security-identity/tests/unit/test_admin_metrics_access.py and tests/integration/test_identity_access_scope.py |
| tools answers only four routes without the gateway’s identity | PASS | /health, /metrics, /openapi.json and /api, none of them routed by the gateway. /health and /api report the status, the service’s version and two counts, and no longer name the environment, the settings or the loaded tools to whoever reaches the service port (#721). The four /api/system/ routes, which also reported the debug flag and the health of the other services, were removed (#646). open-security-tools/tests/unit/test_system_surface.py calls every operation in the service’s schema without credentials and expects 401 |
| Each team reaches only its own data | PASS for guardian, data, responder and cspm | guardian scopes its models, routes and Celery tasks to the caller’s team (#642); data stores and serves sensor telemetry per team (#641), and every count it answers is of the caller’s rows and the global ones: recent_collections of GET /api/v1/data/stats counted every team’s collection runs until #755, and open-security-data/tests/unit/test_team_isolation.py now lists the service’s routes from the application and holds each to a database with two teams’ rows. The integration suite registers two accounts and checks each service through the gateway: test_guardian_tenancy.py, test_data_tenancy.py, test_data_cross_tenant.py, test_telemetry_tenancy.py, test_responder_tenancy.py and test_cspm_tenancy.py all passed on main (Integration Tests, commit bcbc569f) |
| Guardian e-mails a team’s data to that team’s people only | PASS | Every e-mail goes through notify_team (apps/core/notifications.py): to the addresses the team typed into the alert rule or report schedule concerned, to the vulnerability’s assignee, or to the team’s owners and admins, and never to a platform-wide address (#678, #705). guardian keeps no address: its worker asks identity when it sends (POST /internal/team-contacts), which answers active members of the team asked about only, so a former member, a deactivated account and an old address receive nothing, and guardian checks the answer again before using it. The worker presents GUARDIAN_CONTACTS_SECRET, not GATEWAY_INTERNAL_SECRET, which it does not hold; identity does not start when the two are equal. Without a mail server nothing is printed to the log and each notification is recorded as not sent. No address is written to a log. Tested in open-security-guardian/tests/unit/test_notification_delivery.py (every notification, for two teams: each reaches its own people and names nothing of the other), open-security-identity/tests/unit/test_team_contacts.py, tests/scripts/test_guardian_mail_wiring.py and, through the gateway, tests/integration/test_guardian_notifications.py. The addresses a team types into a rule or a schedule are used as they are: removing a person from the team does not remove their address from a rule |
| Guardian’s answers name no internal host | PASS | The next and previous links of a paginated list are relative references under the gateway’s /api/v1/guardian/ path, with no scheme and no host; they were absolute URLs on open-security-guardian, the Host the gateway presents guardian (#643). The path comes from X-Forwarded-Prefix, a literal in the gateway’s guardian location that replaces a client’s own; guardian reads no host header for a link. tests/integration/test_guardian_pagination_links.py follows the links through the gateway, with and without forged X-Forwarded-*, Forwarded, SCRIPT_NAME and Host headers; open-security-guardian/tests/unit/test_gateway_links.py checks the header against the gateway configuration. A redirect’s Location is written back to that path too, and leaves the gateway as a path alone, with no scheme, host or port: it used to be completed with the request’s host name and without the port, so a gateway reached on a port other than 443 sent the client to an address that does not answer (#776). open-security-gateway/test/redirect_tests.sh reads and follows it on the production image. Every other redirect the gateway’s HTTPS listener writes itself is a path alone too, for the whole server and not for guardian’s location only (#788): the 301 for a prefix asked without its trailing slash, and a Location on an upstream’s own name, which the location’s default proxy_redirect turns into a gateway path, so the upstream’s name does not reach the client. The same script asks for each of them. This row is guardian’s all the same: a Location another service writes that no proxy_redirect matches leaves the gateway as that service wrote it |
| Guardian’s rate limit is the configured one | PASS | docker-compose.yml passes GUARDIAN_RATE_LIMIT_USER to guardian, which refuses to start on a malformed value and counts requests per gateway user, not per address (#645). Its throttle for anonymous callers is removed: no anonymous request reaches /api/, and it refused the container’s own health probe. Tested in open-security-guardian/tests/unit/test_rate_limit.py |
| Guardian stores no credential of a scanner or an external system | PASS | A scanner’s api_key and password, an external system’s auth_config, a webhook endpoint’s secret_token and a notification channel’s config were kept in guardian’s database as plain text (the help text of one said “Encrypted”), were never returned by the API and had no reader: guardian connects to no scanner, contacts no external system, receives no webhook and delivers nothing through a channel. The five columns are dropped, with their values blanked first because PostgreSQL’s DROP COLUMN leaves a column’s bytes in the row, and the API answers 400 for a value sent in one of them instead of accepting a secret it would discard (#728). No encryption key was added, since nothing is left to encrypt. open-security-guardian/tests/unit/test_no_stored_credentials.py sends each secret through every write route and reads every table afterwards, runs the migrations on rows that hold secrets, and fails for a model field or a served serializer field named like a secret. Backups written before the upgrade still hold what was stored: see the deployment guide |
| Scans reach no internal target unless the operator lists it | PASS for guardian’s scans and the tools service’s network tools | Both decide with one implementation, open_security_shared.target_policy: private, loopback, link-local, multicast, reserved, shared and cloud-metadata addresses are refused, IPv4 and IPv6, an IPv4 address embedded in an IPv6 one included, and a range is refused whole when one address of it is internal. guardian’s asset discovery and port scans, which run from a worker inside the stack’s networks, had no such check: a discovery of the worker’s loopback, of the stack’s Docker network or of a cloud metadata address was accepted and run (#748). They are now refused where the target is typed (400, nothing queued) and again in the worker, unless the range is in GUARDIAN_ALLOWED_INTERNAL_TARGETS, which is empty by default and stops guardian and its worker at start-up when malformed. The tools service’s policy (#614) answers as before, with TOOLS_ALLOWED_INTERNAL_TARGETS; neither service reads the other’s list. What remains is in the tools README: most tools resolve a checked name again when they connect. One vectors file, tests/shared/target_policy_vectors.json, is run by the shared package’s, the tools service’s and guardian’s suites; tested also in open-security-guardian/tests/unit/test_scan_target_policy.py and, through the gateway, in tests/integration/test_guardian_scan_targets.py |
| Self-service API-key routes reach the caller’s keys only | PASS | The self-service routes match the caller’s user ID as well as the team, so a member cannot read or revoke a teammate’s key; a team owner or admin revokes through the team route (#664). Tested in tests/integration/test_identity_access_scope.py and test_api_key_revocation.py |
| API keys stored as keyed digests | PASS | identity stores an HMAC of each key, keyed with API_KEY_HASH_SECRET (app/auth.py). docker-compose.yml and docker-compose.prod.yml refuse to start without it, and identity refuses to start without it unless ENVIRONMENT is development (#648, #736). Tested in open-security-identity/tests/unit/test_api_key_hash_secret.py |
| Agent tasks reachable by their owner only | PASS | Reading or cancelling an analysis fails closed: a missing owner record or another user’s task answers 404. The analyze rate limit is keyed by the verified gateway user, with an optional per-team ceiling, instead of the gateway’s address (#650, #651). Tested in open-security-agents/tests/unit/test_task_ownership.py and test_analyze_rate_limit.py. The agent’s tools call the tools, data and guardian services with the identity of the user who submitted the analysis, so each service answers for that user’s team; the agents service holds no key that sees every team (#652). The two tools that read a team’s own data (its indicators and Guardian vulnerabilities) are not given to the model unless the operator names them in AGENT_TEAM_DATA_TOOLS: enabling one sends what it returns to the model provider and places it in the model’s context beside text fetched from the internet, a prompt-injection path the prompt alone does not close. Tested in test_tools_act_as_the_caller.py, test_team_data_tools.py and tests/integration/test_agents_tools_identity.py |
| Every API endpoint enforces authentication | Not verified | Not checked endpoint by endpoint behind the gateway |
| JWT tokens revocable | PASS | POST /auth/logout and POST /auth/jwt/logout blacklist the token’s jti in Redis; the gateway and identity’s own routes refuse it (logout.py, user_manager.py) |
| Account lockout after failed logins | PASS | After 5 failed password logins an account, registered or not, is refused with 429 for 15 minutes, whatever the password; a successful login clears the counter (#509). Tested in tests/integration/test_login_lockout.py |
| CORS without wildcards, for listed origins only | PASS | No literal allow_origins=["*"] or wildcard Access-Control-Allow-Origin in service code or gateway config, with one exception: the tools service’s settings validator (validate_cors_origins in open-security-tools/app/config.py) returns ["*"] for an empty CORS_ORIGINS. No client receives that through the gateway, which removes what a service sets. The gateway decides CORS for the API in one place (lua/cors.lua, includes/cors.conf): it answers a preflight, and names an origin in a response, only for the origins in CORS_ORIGINS, always the specific origin with Vary: Origin, and removes what a service sets. It does not start with an entry that is not an origin, a wildcard included. A preflight used to be answered 405 in the production configuration and the harness checked another one (#712). Tested against the production image and configuration in open-security-gateway/test/cors_tests.sh, and at startup in startup_config_tests.sh |
| Security headers at the gateway | PASS | HSTS, X-Frame-Options, X-Content-Type-Options and Permissions-Policy set in wildbox_gateway.conf |
| API schema and docs served in development only | PASS | The six FastAPI services (identity, tools, data, responder, agents, cspm) take their docs_url, redoc_url and openapi_url from one shared rule, open_security_shared.api_docs: /openapi.json, /docs and /redoc answer 200 when ENVIRONMENT is development and 404 for any other value, production and staging included, and when the variable is unset or empty: a service started without it took the missing value for development (#722). The sensor’s local API follows the same rule for its route list at / and /docs, which it served unless ENVIRONMENT was production; DEBUG no longer decides for cspm (#679, #496). tools has no /docs or /redoc in any environment (#581). Tested by tests/unit/test_api_docs_exposure.py in each of the six services, which requests the three paths from the real application under each environment, and by tests/shared/test_api_docs.py. The gateway maps no client path to any of them: every backend location in wildbox_gateway.conf proxies to a prefixed path (/api/v1/, /v1/, /api/tools/, /api/tasks/, /stats, /health), so in development they are reachable only on the services’ 127.0.0.1 ports and inside the Compose network. guardian, the one Django service, serves /api/schema/, /docs/ and /redoc/ only when DEBUG is true, outside the gateway’s /api/v1/ mapping as well |
No bare except: |
PASS, one exception | Only open-security-responder/demo_final.py, a demo script |
| TLS verification enabled in all tools | PASS | Scanners verify certificates by default and report a failed verification instead of falling back; accepting an invalid certificate needs verify_ssl: false on that scan. The three certificate analyzers keep an unverified handshake to read broken certificates and report an untrusted one as a finding (#495) |
| No Docker socket mounts | PASS | No tracked Compose file mounts a container runtime socket. The gateway’s standalone development file ran a logspout log viewer that mounted /var/run/docker.sock read-only (:ro restricts the file, not the Docker API); the service was removed, and later the file itself, which could not start (#680). scripts/check_container_hygiene.py, run by the Code Quality job, reads every tracked Compose file (the root files, the overlays, the per-service ones and those under .github/) and fails on such a mount; its allow-list holds no socket entry. Tested in tests/scripts/test_check_container_hygiene.py |
| Compose images name a version, no privileged containers | PASS | The same check fails on an image: with no tag, with latest or with a tag that names no version (nginx:alpine), and on privileged, cap_add, devices, a host namespace or an unconfined seccomp or AppArmor profile. No Compose file has any of them (#680). Since #726 the check also requires the digest of the image index next to the tag (redis:7-alpine@sha256:...), in every tracked Compose file: a tag is published again with each rebuild upstream. Dependabot’s docker-compose ecosystem proposes new digests weekly, and newer minor and patch tags; majors are decided by hand |
| Development stacks publish on loopback | PASS | The same check fails on a port published on every interface. The standalone per-service Compose files bind their APIs, Redis and PostgreSQL to 127.0.0.1. The exceptions are listed with their reasons in scripts/container_hygiene_allowlist.txt: the gateway’s 80, 443 and 8080, and the reverse proxy of the standalone tools stack |
| Image builds install only what a hash vouches for | PASS | Every Python image installs its dependencies with pip install --require-hashes --no-build-isolation -r requirements.txt and the shared package with --no-index --no-build-isolation, using the pip (24.0) of the digest-pinned base image and the setuptools already in the image: the base image’s 79.0.1, or the version a lockfile pins by hash, as data’s does. cspm, guardian, responder and tools no longer run pip install --upgrade pip first, and no image lets pip download setuptools to build the shared package, which all eight did (#657). The dashboard images run npm ci. scripts/check_container_hygiene.py, run by the Code Quality job, fails on any other pip install or npm install in a tracked Dockerfile; these rules have no allow-list. Tested in tests/scripts/test_check_container_hygiene.py |
A missing ENVIRONMENT is never development; start-up checks apply outside development |
PASS | docker-compose.yml requires ENVIRONMENT (${ENVIRONMENT:?}) for the 11 services it passes it to, the ones whose code reads it (identity, the three tools containers, data and its scheduler, responder, cspm and its worker, agents, the sensor), where it defaulted to development; docker-compose.prod.yml sets production on the same 11; no tracked Compose file gives the variable a default; scripts/validate_secrets.py requires it (#736). identity (API_KEY_HASH_SECRET), data (SECRET_KEY, DATABASE_URL, DEBUG off) and tools (API key) apply their start-up checks to every environment that is not development, by open_security_shared.environment, where they applied them to the exact value production. Tested by tests/scripts/test_environment_fail_closed.py, which also renders the files with docker compose config, tests/shared/test_environment.py, and test_startup_checks.py and test_api_key_hash_secret.py in the services |
| Image environments satisfy what is installed in them | PASS | pip check reports no broken requirement in the eight Python images (built from the Dockerfiles, 5 October 2026); it reported one or more in seven of them, where the shared package declared dependencies the image did not have (#722). The shared package has no core dependency and one extra per group of modules; each Dockerfile installs it offline with the extras of the modules its service imports and then runs pip check, so an image whose lock lacks a requirement, or pins it below the declared floor, does not build (Docker Build Validation). scripts/check_shared_dependencies.py, run by the Dependency Integrity job, fails when the extras differ from what the service imports, when a lock does not meet them, or when a shared module imports something its extras do not require. Tested in tests/scripts/test_check_shared_dependencies.py |
| Base images pinned by digest, downloads checked | PASS | The same check fails on a FROM without @sha256:, on a download piped to a shell, and on a curl, wget or ADD <url> whose result nothing in the same instruction checks. The three downloads in the images carry a SHA-256: Trivy (tools), osquery (sensor) and lua-resty-http (gateway), which were pinned by version only (#657). Not covered: apt-get install and apk add take the distribution’s current package versions on top of the pinned base image |
| Development reloader off in production | PASS | identity’s scripts/init.sh passes --reload to uvicorn only when ENVIRONMENT is development (#664). Tested in tests/scripts/test_identity_init_reload.py |
| SSRF protection on outbound requests | Not verified | |
| File upload validation | Not verified | |
| LLM input sanitization | Not verified | |
| CI secrets externalized | Not verified | secret-scan.yml runs on every PR; the workflows were not reviewed one by one |
History
The November 2025 platform audit (its report, remediation checklist and improvements summary) is no longer published: it described code that has since changed, and its findings are superseded by this page. The tools service audit of November 2025 is not published either, for the same reason; it is kept in the repository as docs/security/tools-service-audit.md.
The audit rounds of February 2026 (v0.5.2 to v0.5.5) and every later security change are recorded release by release in CHANGELOG.md.