Skip to content

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 viewer cannot see credential plaintexts. An auditor cannot 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:
    1. ENCRYPTION_KEY environment variable (required in production — the backend refuses to auto-generate a key when APP_ENV is not dev/test, and an invalid-format key is a startup error, not a silent replacement)
    2. /data/secret.key on 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_SECRET env variable (HS256 signing key)
  • If unset or weak, the runtime attempts to generate one and persist it to /data/.jwt_secret
  • If /data is not writable, startup continues but the generated secret is not persisted; the next rebuild or restart may invalidate sessions. Set JWT_SECRET explicitly 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 — prefer user + a project-level viewer membership).
  • Project roles: 11 roles from owner down to auditor, keyed on ROLE_PERMISSIONS in backend/app/core/permissions.py.
  • The security-relevant distinction: credentials.read_secret is granted only to owner, admin, editor and operator; findings.approve only to reviewer. 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.

  1. REST endpoint handlers call check_pid_access(db, pid, user, "<permission>") (or check_object_access / user_has_permission). Returns 403 on refusal, 404 when user is not a project member at all.
  2. WebSocket broadcast (backend/app/ws.py) filters per recipient against _ENTITY_POLICY. If the recipient lacks the entity's read permission, the event is dropped. If the recipient has read but not a redact permission (e.g. credentials.read_secret), the sensitive field is replaced with "" in the per-recipient payload. Global admins bypass.
  3. require_admin dependency 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 without project_ids (global) remains admin-only to create / edit / delete.
  • A non-admin cannot widen project_ids to 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 recomputed
  • unverified — rows created before integrity fingerprints were introduced
  • file_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:

  1. Admin calls POST /api/admin/modules/sign with the plugin source. The endpoint runs the same AST validation and returns a sha256=<hex> HMAC over the content.
  2. Admin uploads via POST /api/admin/modules/upload with the X-Plugin-Signature header.
  3. 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 HEAD or 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_SECRET set to a long random value via env / secret manager
  • [ ] ENCRYPTION_KEY set the same way (production refuses to run on an auto-generated or invalid key)
  • [ ] DB_PASSWORD and ADMIN_PASSWORD rotated 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 admin user 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_events where entity="audit" for anomalies (off-hours mass exports, repeated secret reads, etc.)
  • [ ] WEBHOOK_HMAC_SECRET set if any webhook endpoint is reachable from hosts you do not fully control (see §7)
  • [ ] ENCRYPTION_KEY rotation procedure documented; backend/scripts/rekey.py available 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_KEY set via env / secret manager; run GET /api/admin/audit/verify after first start to confirm HMAC is being applied to new events (see §5)
  • [ ] AUDIT_S3_BUCKET configured with Object Lock if you need host-root-resistant audit immutability (see §5)
  • [ ] PLUGIN_SIGNING_KEY set and PLUGIN_REQUIRE_SIGNATURE=true if you run server-side plugins in a team environment (see §10)