Security¶
This document describes Umbryx' security model: what is protected and how. It is the canonical reference for operators standing the platform up and for anyone reviewing the code.
1. What Umbryx protects¶
Umbryx holds operational data from an active pentest or red-team engagement. The protected surface is:
| Asset | Why it matters | Where it lives |
|---|---|---|
| Credentials (passwords, NTLM hashes, Kerberos tickets, SSH keys) | Direct authentication material to client systems | creds table, encrypted with a per-project key |
| Notes tagged confidential | Free-form text that may contain undisclosed findings, victim names, operational details | notes.content if tag ∈ {confidential, secret, sensitive, opsec, restricted}, encrypted. Attachments encrypted too; download requires credentials.read_secret (re-review 2026-11-02: was plain notes.read) |
| Loot text artifacts (extracted hashes, secrets, config snippets) | Same risk class as credentials | loots.value for non-file artifacts, encrypted |
| C2 operator material (Adaptix tokens, Mythic apitokens, Sliver operator config JSON) | Whoever holds them controls live attacker infrastructure | global_settings.c2_integrations, encrypted token+password fields |
| Uploaded artifact files (NTDS dumps, ZIP archives, screenshots) | Often contain the most damaging raw material | data/uploads/, Fernet-encrypted on filesystem |
| Findings, host inventory, network maps | Aggregated picture of the engagement; valuable to anyone reconstructing it | PostgreSQL, plaintext |
Audit log (timeline_events with entity="audit") |
Forensic evidence of who-saw-what | PostgreSQL + HMAC fingerprint per row + append-only JSONL mirror |
2. Whom Umbryx protects against¶
Umbryx assumes a trusted internal network deployment. The model defends against:
- Curious or unprivileged platform users. A
viewercannot see credential plaintexts. Anauditorcannot see credentials at all. Permission gates exist at REST endpoints AND at the WebSocket broadcast layer (per-recipient filtering + field redaction). - Cross-project leakage. A user who is a member of project A but not project B cannot list, read, or search project B's data. The same gate covers WebSocket subscriptions.
- Casual database access at rest. A snapshot of the PostgreSQL volume alone does not yield credential plaintexts — they are encrypted with per-project data-encryption keys (see §4).
- Forgotten audit trail. When secrets are decrypted for use
(credential reveal, host actions panel, bulk exec, C2 exec, project
export), an
audit/...event is written to the timeline with the actor, the cred id, and the context (secret_revealed,secret_used_bulk_exec,secret_used_c2_exec,export_with_secrets, and related events — see §6).
Deployment expectations that are your responsibility, not the platform's: TLS termination and IP allow-listing at a reverse proxy, host-level disk encryption, and endpoint hygiene for operators.
3. Encryption¶
Algorithm¶
Symmetric authenticated encryption via cryptography.fernet.Fernet:
- AES-128 in CBC mode for confidentiality
- HMAC-SHA256 over the ciphertext for integrity
- Random IV per encryption
Implementation: backend/app/core/crypto.py (global key) and
backend/app/core/key_management.py (per-project keys).
Ciphertext storage conventions: values encrypted with the global
key are prefixed with __enc__:; values encrypted with a per-project
DEK are prefixed with __pdek__:. Legacy plaintext values pass
through unchanged during decryption, and a value that looks encrypted
but cannot be decrypted raises an error instead of being silently
returned as if it were the plaintext.
What is encrypted¶
| Field | Encrypted? | Notes |
|---|---|---|
creds.secret |
✅ | password / NTLM / ticket / SSH key; per-project DEK |
notes.content |
✅ conditional | only when the note has a tag in {confidential, secret, sensitive, opsec, restricted} |
loots.value |
✅ for non-file artifacts | text field for "hash" / "secret" / "text" loot types |
loots uploaded files |
✅ | Fernet-encrypted on disk (file_encrypted=TRUE rows) |
global_settings.c2_integrations[].token and .password |
✅ | per-integration, global key |
| Everything else (hosts, findings, host activities, jobs, network map, timeline, etc.) | ❌ | considered operationally needed in plaintext |
If your database predates the loot-file encryption migration, run the
provided migrations and re-upload or re-import the affected artifacts
so that everything in data/uploads/ is stored encrypted.
Key hierarchy¶
- Global key (KEK) is resolved in this order:
ENCRYPTION_KEYenvironment variable (required in production — the backend refuses to auto-generate a key whenAPP_ENVis not dev/test, and an invalid-format key is a startup error, not a silent replacement)/data/secret.keyon disk (chmod 0600), used in dev/test
- Per-project DEKs wrap credential secrets: each project gets its
own data-encryption key, itself encrypted under the global key
(table
project_encryption_keys). Rotating a project's DEK (POST /api/system/encryption/rotate-dek) re-wraps that project's credentials without touching anything else.
Strong key management requires the env var: inject the key via your secret manager (Vault, sops, Kubernetes Secret) and do not let it land on the data volume.
JWT secret is separate from the encryption key:
JWT_SECRETenv variable (HS256 signing key)- If unset or weak, the runtime attempts to generate one and persist
it to
/data/.jwt_secret - If
/datais not writable, startup continues but the generated secret is not persisted; the next rebuild or restart may invalidate sessions. SetJWT_SECRETexplicitly for any persistent deployment.
4. RBAC¶
Two layers: a global role on every user, and a project role per project membership. See Access control for the full role tables and the permission matrix.
- Global roles:
platform_admin,user,viewer(legacy read-only global role — preferuser+ a project-levelviewermembership). - Project roles: 11 roles from
ownerdown toauditor, keyed onROLE_PERMISSIONSinbackend/app/core/permissions.py. - The security-relevant distinction:
credentials.read_secretis granted only toowner,admin,editorandoperator;findings.approveonly toreviewer. Every other role sees redacted or read-only data.
Enforcement points¶
The same gate is consulted in three places. Bypassing one does not bypass the others.
- REST endpoint handlers call
check_pid_access(db, pid, user, "<permission>")(orcheck_object_access/user_has_permission). Returns 403 on refusal, 404 when user is not a project member at all. - WebSocket broadcast (
backend/app/ws.py) filters per recipient against_ENTITY_POLICY. If the recipient lacks the entity'sreadpermission, the event is dropped. If the recipient hasreadbut not a redact permission (e.g.credentials.read_secret), the sensitive field is replaced with""in the per-recipient payload. Global admins bypass. require_admindependency at the FastAPI layer for endpoints that have no project context (user CRUD, module toggles, unscoped C2 CRUD).
Admin access auditing¶
When a platform_admin user accesses a project endpoint without
project membership (i.e., full bypass), an audit/admin_bypass_access
event is written to the project's timeline_events table. This record:
- Is visible in the project timeline to project members
- Is committed immediately, independently of the main request transaction
- Includes: actor username, user ID, and the permission that was checked
Access via project membership (admin who is also an owner/editor
etc.) is not audited by this mechanism — it follows normal
per-role rules.
Project-scoped C2 integrations¶
A project owner can register their own C2 integration
(Adaptix / Mythic / Sliver) bound to one or more projects they own.
Constraints:
- Non-admins must supply
project_ids; an integration withoutproject_ids(global) remains admin-only to create / edit / delete. - A non-admin cannot widen
project_idsto include a project they don't own, nor empty it (which would promote the integration to global). - Visibility (list endpoint): admins see all; project members see integrations bound to their projects.
5. Audit log¶
Audit events live in the same timeline_events table as ordinary
project events, distinguished by entity = "audit". Coverage:
| Event | When |
|---|---|
secret_revealed |
A user with credentials.read_secret reveals a credential secret via GET /api/creds/{cid}/secret (rate-limited) |
read_credential_secrets |
The host actions panel reveals project creds matched to a host |
secret_used_bulk_exec |
A credential is bound to a bulk_exec job |
secret_used_validate |
A credential is bound to a validate_cred job |
secret_used_c2_exec |
A credential is substituted into a C2 exec command line |
export_with_secrets |
A project export is generated and the exporter has credentials.read_secret |
read_sensitive_loot |
Sensitive loot values are decrypted for display (list endpoints, including global admin/member listings) |
read_confidential_notes |
Confidential note content is decrypted for display |
download_sensitive_file |
A loot file or note attachment is downloaded via /api/uploads/... |
webhook_token_regenerated |
A project webhook token is rotated |
admin_bypass_access |
A global admin accesses a project endpoint without project membership |
Audit events are visible in the project timeline; they are not deleted when an entity is deleted, so the trail survives cleanup.
What is not in the audit log:
- WebSocket event deliveries
- Read endpoints that return only metadata (e.g. host list)
- Failed access attempts (403 / 404) — these go only to application logs
Audit log hardening¶
Every log_event() call uses three independent persistence channels:
| Channel | Where | Tamper-detectable | Immutable by default |
|---|---|---|---|
DB row (timeline_events) |
PostgreSQL | ✅ via HMAC integrity field |
❌ DB admin can DELETE |
| JSONL file | UPLOAD_ROOT/audit/timeline.jsonl (O_APPEND) |
— | ❌ host-root can edit |
| S3/Minio object | configured bucket, one object per event | — | ✅ with Object Lock / WORM |
HMAC integrity: when AUDIT_INTEGRITY_KEY is set, each event gets
a sha256=<hex> fingerprint over the canonical fields (id, pid,
entity, action, label, ts). GET /api/admin/audit/verify checks all
stored rows, reporting:
tampered— event IDs whose stored hash no longer matches recomputedunverified— rows created before integrity fingerprints were introducedfile_only— event IDs in the JSONL file but absent from DB (indicates a row was deleted after it was logged)
To enable:
# Add to .env:
AUDIT_INTEGRITY_KEY=$(openssl rand -hex 32)
AUDIT_LOG_DIR=/data/uploads/audit # default
# Optional S3/Minio WORM forward:
AUDIT_S3_BUCKET=my-worm-bucket
AUDIT_S3_ENDPOINT_URL=https://minio.internal
6. AI chat¶
Umbryx includes an optional AI chat feature
(POST /api/projects/{pid}/ai/chats/{cid}/messages and its streaming
variant).
What data is sent to the LLM provider¶
When a chat request is made, the system prompt sent to the configured LLM provider includes:
- Counts only of project entities (host count, credential count, finding count, note count, loot count, scope count, attack-step count)
- The full conversation history submitted by the operator in the request body
Sensitive values are not included in the system prompt — no credential secrets, no note content, no uploaded file content. The AI agent can call tools (read hosts, findings, notes, creds metadata) under the asking user's own permissions, so the operator's questions and AI tool-call results may include project entity names, hostnames, IPs, and finding titles. An outbound anonymization mode (reversible tokenization of everything sent to the provider) can be enabled for chats.
Privacy and opsec implications¶
- All chat messages and tool-call contents are transmitted to the configured external LLM provider (OpenAI-compatible API). The provider may log or train on this data per their own terms of service.
- Do not include client names, unreported vulnerabilities, or other sensitive engagement details in AI chat prompts if your engagement agreement prohibits data sharing with third parties.
- For air-gapped or sensitive engagements: use a locally-hosted OpenAI-compatible endpoint (e.g. Ollama, vLLM) configured via the AI providers settings.
Kill switch¶
The AI module can be disabled globally by an admin via the module
toggle (POST /api/modules/ai/disable).
Configuration¶
Provider configuration lives under GET /api/ai/providers (API keys
masked — last 4 chars shown) and PUT /api/ai/providers/{id}. Treat
the AI provider configuration as sensitive application data and review
the current implementation before using external providers on
restricted engagements.
7. Webhook HMAC authentication¶
Umbryx verifies HMAC-SHA256 signatures on incoming webhook requests
(Sliver, Havoc, custom). Verification is
fail-closed: if WEBHOOK_HMAC_SECRET is not configured on the
server, webhook ingestion is rejected outright rather than accepted
unsigned. Set the secret before enabling webhook ingestion:
# Generate a 32-byte hex secret:
openssl rand -hex 32
# Then add to .env:
WEBHOOK_HMAC_SECRET=<output>
When set, every incoming POST /api/webhooks/{token} request must
include an X-Hub-Signature-256: sha256=<hmac> header computed over
the raw request body with the shared secret. Requests without a valid
header are rejected with 403.
The GET /api/projects/{pid}/webhook endpoint returns
"hmac_required": true/false so operators can verify the current
configuration.
Pre-deployment checklist item: ensure WEBHOOK_HMAC_SECRET is set in
any deployment where the webhook endpoint is reachable from hosts you
do not fully control.
8. ENCRYPTION_KEY rotation¶
Why rotation is risky without a migration tool¶
Fernet keys are non-rotatable without re-encrypting the data. If you
update ENCRYPTION_KEY in your environment without first re-encrypting
all stored ciphertexts, the following data becomes permanently unreadable:
- All credential secrets
- All confidential/restricted note contents
- All sensitive loot values (hashes, secrets, text artifacts)
- All uploaded loot files marked
file_encrypted=True - C2 integration token and password fields
- Per-project DEKs (wrapped under the old global key)
Backups created with the old key are also unreadable under the new key unless you decrypt them first.
Rotation procedure¶
Use the bundled backend/scripts/rekey.py CLI:
# 1. Generate a new Fernet key
NEW_KEY=$(python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
echo $NEW_KEY # save this somewhere safe
# 2. Dry-run: count affected rows without writing
OLD_KEY=<current_key> NEW_KEY=$NEW_KEY \
python3 backend/scripts/rekey.py --dry-run
# 3. If counts look correct, run the actual migration
# (do this with the API stopped or in maintenance mode)
OLD_KEY=<current_key> NEW_KEY=$NEW_KEY \
python3 backend/scripts/rekey.py
# 4. Update ENCRYPTION_KEY in your environment / secret manager
# 5. Restart the backend container
The script also accepts positional arguments:
python3 rekey.py <old_key> <new_key> [--dry-run]
Required env vars for DB connection: DATABASE_URL (or
DB_HOST / DB_PORT / DB_NAME / DB_USER / DB_PASSWORD).
UPLOAD_ROOT defaults to /data/uploads.
What the script re-encrypts¶
| Source | Column / field |
|---|---|
credentials |
secret (values under the global key) |
notes |
content (only __enc__: prefixed rows) |
loots |
value (only __enc__: prefixed rows) |
loots |
on-disk files where file_encrypted=TRUE |
global_settings |
c2_integrations[*].token and .password |
Per-project DEKs are rotated separately via
POST /api/system/encryption/rotate-dek, which re-wraps a project's
DEK under the (new) global key and re-encrypts that project's
credentials. After a global rekey, rotate each project's DEK (or plan
the rekey to include the DEK-wrap step) — otherwise DEK-wrapped values
stay unreadable under the new global key.
Backup first¶
Always take a database dump and a copy of data/uploads/ before
running rekey. Fernet decryption with the wrong key raises
InvalidToken and the script will exit with a non-zero status —
original data is preserved in that case because the script uses a
single transaction.
9. Notification opsec (Telegram / Slack)¶
Umbryx can dispatch alerts to Telegram and Slack via the notification module. This requires outbound HTTP/HTTPS to external internet hosts from the machine running the backend container.
Opsec implications¶
| Risk | Detail |
|---|---|
| Traffic leakage | Alert content (finding titles, severity, host names) is transmitted to api.telegram.org or hooks.slack.com. In air-gapped or restricted networks, this traffic may be visible to network monitoring. |
| Provider logging | External providers may log message content per their terms of service. Do not include client names or unreported vulnerability details in finding titles if your engagement agreement prohibits sharing with third parties. |
| DNS leakage | Even if HTTPS is proxied, the DNS lookup for api.telegram.org is observable on the local network. |
| Air-gapped conflict | If the deployment machine has no outbound internet access, notification delivery fails with an error in application logs — no retries are queued. |
Recommendations¶
- For air-gapped or VPN-isolated deployments: disable the notification
module (
POST /api/modules/notifications/disable). - Use a self-hosted webhook relay (mattermost, Matrix, local SMTP) and configure Umbryx' custom webhook integration instead.
- Treat finding titles and host names as potentially sensitive — keep them opsec-safe in case they appear in notification bodies.
10. Plugin system security¶
Umbryx supports server-side Python plugins. Plugins execute in-process — a loaded plugin shares the backend's memory and can call any Python API. Controls address supply-chain substitution and accidental dangerous patterns, not a determined malicious admin.
AST validation on upload¶
Source modules uploaded through the admin module interface are scanned at the AST level before they are saved:
| Pattern | Disposition |
|---|---|
eval, exec, __import__, compile |
Hard 400 rejection — upload refused |
subprocess, socket, ctypes, multiprocessing |
Upload proceeds; warning returned in response and logged |
HMAC plugin signing¶
When PLUGIN_SIGNING_KEY is set:
- Admin calls
POST /api/admin/modules/signwith the plugin source. The endpoint runs the same AST validation and returns asha256=<hex>HMAC over the content. - Admin uploads via
POST /api/admin/modules/uploadwith theX-Plugin-Signatureheader. - The upload endpoint verifies the header before saving.
With PLUGIN_REQUIRE_SIGNATURE=true (the default once a signing key is
present), unsigned uploads are rejected 403. This ensures files are not
substituted between the sign and upload steps.
Residual risk: the signing endpoint and the DB run on the same host. A host-root attacker can replace the key. Plugin signing addresses casual supply-chain substitution and CI/CD pipeline hygiene, not an admin-level adversary.
Configuration¶
# .env
PLUGIN_SIGNING_KEY=$(openssl rand -hex 32)
PLUGIN_REQUIRE_SIGNATURE=true # enforce (default true)
11. Reporting a vulnerability¶
Open an issue on GitHub for non-sensitive reports. For sensitive disclosures, email the maintainer privately (see the repository README for contact). Please include:
- Affected version (
git rev-parse HEADor release tag) - A minimal reproducer
We aim to acknowledge within 5 business days. Coordinated disclosure windows are negotiated case-by-case.
12. Pre-deployment checklist¶
Before pointing real engagement data at a Umbryx instance:
- [ ]
JWT_SECRETset to a long random value via env / secret manager - [ ]
ENCRYPTION_KEYset the same way (production refuses to run on an auto-generated or invalid key) - [ ]
DB_PASSWORDandADMIN_PASSWORDrotated from defaults - [ ] Reverse proxy in front (TLS termination, rate limit, IP allow-list)
- [ ]
/data/uploads/on a volume with at-rest encryption (e.g. LUKS, cloud-managed disk encryption) - [ ] Default
adminuser has been disabled or had its password rotated after the first real operator account is created - [ ] Backup strategy for the database — including the
ENCRYPTION_KEY, otherwise backups are unrecoverable - [ ] Monitor
timeline_eventswhereentity="audit"for anomalies (off-hours mass exports, repeated secret reads, etc.) - [ ]
WEBHOOK_HMAC_SECRETset if any webhook endpoint is reachable from hosts you do not fully control (see §7) - [ ]
ENCRYPTION_KEYrotation procedure documented;backend/scripts/rekey.pyavailable for key migration (see §8) - [ ] Notification module disabled or configured with a self-hosted relay if operating in an air-gapped or VPN-isolated environment (see §9)
- [ ]
AUDIT_INTEGRITY_KEYset via env / secret manager; runGET /api/admin/audit/verifyafter first start to confirm HMAC is being applied to new events (see §5) - [ ]
AUDIT_S3_BUCKETconfigured with Object Lock if you need host-root-resistant audit immutability (see §5) - [ ]
PLUGIN_SIGNING_KEYset andPLUGIN_REQUIRE_SIGNATURE=trueif you run server-side plugins in a team environment (see §10)