Configuration¶
Every option in settings.json and the deployment .env.
Configuration Guide — Suspicious Platform¶
This document describes every configuration file and environment variable required to deploy and run Suspicious.
Configuration Files Overview¶
| File | Location | Purpose |
|---|---|---|
.env |
deployment |
Docker Compose runtime: ports, paths, credentials, versions |
settings.json |
Suspicious/ |
Main application: branding, behavior, integrations, mail |
config.json |
email-feeder/ |
Email ingestion: mailbox connectors, storage, polling, notifications |
make initchecks for these files and — if missing — creates them from sample templates (.env.example,settings-sample.json,config-sample.json). It also verifies directory structure, permissions, and certificates.Tested baseline. The full 13-service stack was brought up end-to-end with the image versions in §1.1. Four settings decide whether it starts and serves traffic — get these right first:
Setting Rule settings.jsondatabase.passwordmust equal .envMYSQL_PASSWORD(no Vault) — §2.5settings.jsonapp.debugtruefor plain HTTP onlocalhost;falseforces HTTPS via Traefik — §2.1settings.jsonapp.allowed_hostsinclude DOMAIN_CORP, or the Traefik path returnsDisallowedHost— §2.1.envCORTEX_PATHabsolute path when running Cortex — §1.7
1. .env — Deployment Environment¶
cp .env.example .env
1.1 Application Versions¶
SUSPICIOUS_VERSION=latest # `latest` tracks main; pin a tag for prod
# UI + feeder default to SUSPICIOUS_VERSION when unset; pin independently if needed.
SUSPICIOUS_UI_VERSION=
SUSPICIOUS_FEEDER_VERSION=
DB_SUSPICIOUS_VERSION=11.4
RUSTFS_VERSION=1.0.0-alpha.90
CORTEX_VERSION=4.1.0-1
ELASTICSEARCH_VERSION=8.19.23
TRAEFIK_VERSION=v3.6
CHROMADB_VERSION=1.5.5
CHROMA_PROXY_VERSION=1.29-alpine # nginx in front of chromadb (token check)
REDIS_BROKER_VERSION=9-alpine
REDIS_CACHE_VERSION=9-alpine
Two more image tags are read by compose but not listed in .env.example; set
them only to pin:
| Variable | Default | Image |
|---|---|---|
AI_INFERENCE_VERSION |
local |
aimailanalyzer-inference:<tag>, built locally by the ai profile |
SUSPICIOUS_MAIL_RENDERER_VERSION |
SUSPICIOUS_VERSION, else latest |
ghcr.io/thalesgroup-cert/suspicious-mail-renderer:<tag> |
This set was brought up together successfully. Only change versions when you know they are compatible — mismatched versions can break services.
1.2 Service Ports¶
SUSPICIOUS_PORT=9020
SUSPICIOUS_UI_PORT=9021
MINIO_PORT=35000 # MinIO / RustFS console — local access only
CORTEX_PORT=9001
ELASTICSEARCH_PORT=9200 # bound to 127.0.0.1; not published externally
1.3 Network Configuration¶
DOMAIN_CORP=your.corporate.domain
NETWORK_NAME=suspicious_net
NETWORK_SUBNET=172.20.0.0/16
NETWORK_GATEWAY=172.20.0.1
NETWORK_IP_RANGE=172.20.0.0/24
DOMAIN_CORP is used by Traefik for TLS/Host routing. Adjust network settings only if you need to avoid conflicts with existing Docker networks.
1.4 Database Credentials¶
MYSQL_DATABASE=db_suspicious
MYSQL_USER=suspicious
MYSQL_PASSWORD="your_db_user_password"
MYSQL_ROOT_PASSWORD="your_db_root_password"
⚠️ Must be set before first startup. MariaDB applies these only when it initialises an empty data volume. Changing them later — or reusing a volume created with different credentials — gives
Access denied for user 'suspicious'. To reset for a fresh start (this erases all data):docker compose rm -sf db_suspicious && docker volume rm suspicious_db_suspicious_data.
MYSQL_PASSWORDmust matchsettings.json→database.password(see §2.5).
1.5 MinIO / RustFS Credentials¶
MINIO_ROOT_USER=minio
MINIO_ROOT_PASSWORD="your_minio_password"
Used by both Suspicious and Email-Feeder to store attachments, artifacts, and processed data.
1.6 Container Names¶
DB_CONTAINER=db_suspicious
WEB_CONTAINER=suspicious
1.7 Local Paths¶
ROOT_PATH=../
SUSPICIOUS_PATH=../Suspicious
SUSPICIOUS_UI_PATH=../suspicious-ui
FEEDER_PATH=../email-feeder
DOCKER_PATH=../docker
YARA_PATH=../yara-rules
MISP_PATH=../misp
CORTEX_PATH=/opt/suspicious/cortex # ABSOLUTE when running Cortex (see note)
DOCKER_SOCK_GID=999 # gid of /var/run/docker.sock (Cortex job runner)
CUSTOM_ANALYZERS_PATH=../Analyzers # custom analyzers; also the AIMailAnalyzer build context
CA_PATH=./certificates
TRAEFIK_PATH=../traefik
CORTEX_PATHmust be an absolute path when the Cortex service runs. Cortex mounts analyzer job directories into sibling containers through the Docker socket, so the host and in-container paths must be identical. A relative value fails at startup withinvalid mount path: '../cortex/jobs' mount path must be absolute. A relative path is fine if you do not run Cortex (core dev stack).
MAIL_RENDERER_PATH (default ../mail-renderer) is the build context of the
mail_renderer image in docker-compose.override.yml; it is not in
.env.example. Set it only if the directory lives elsewhere.
1.8 Proxy Settings¶
HTTP_PROXY=
HTTPS_PROXY=
NO_PROXY=localhost
CORTEX_NO_PROXY=localhost,127.0.0.1,cortex,elasticsearch,rustfs,chromadb,vault,suspicious
Leave blank unless your environment requires an outbound proxy. These values are
also passed as build args to docker compose build, so set them before
building images on a proxy-only network — otherwise the build fails with a
uv pip install (PyPI) or npm connection timeout.
CORTEX_NO_PROXY is the no_proxy list Cortex gets instead of NO_PROXY:
Cortex's Kubernetes client rejects * wildcards and will not boot. Override it
only when Cortex must reach corporate-internal hosts directly; use dot-form
(.corp.example), never *, and keep the internal service names.
1.9 Worker, feeder and AI variables¶
| Variable | Default | Effect |
|---|---|---|
CELERY_CONCURRENCY |
4 |
Prefork pool size of suspicious_celery. Keep it at 4 or more: the ai_model shadow seeding holds one worker for up to about 31 minutes per run, and live AI analysis, finalisation and sync tasks share the pool. |
CELERY_MAX_MEMORY_PER_CHILD_KB |
1000000 |
A child is recycled after the task that pushes it past this RSS (KiB). |
CELERY_MEM_LIMIT |
8g |
Container memory cap; Docker restarts the worker if it exceeds it. |
BACKEND_URL / FEEDER_API_TOKEN |
http://suspicious:9020 / empty |
Feeder config authority, see Config authority. Both blank keeps the feeder on its local config.json. |
FEEDER_CA_BUNDLE |
/etc/private/rootcafile.pem |
CA bundle the feeder uses for HTTPS calls (REQUESTS_CA_BUNDLE). Set it when the backend or an IMAPS server is signed by a different CA. Not in .env.example. |
AI_INFERENCE_TOKEN |
empty | Bearer token of the ai_inference /v2 API. Create it by hand (openssl rand -hex 24): neither make init nor gen-service-secrets.sh generates it. Put the same value in integrations.ai_model.token. Empty makes the token-protected routes answer 503. |
AI_BUNDLE_HOSTS |
rustfs:9000 |
Hosts the server may use for presigned dataset and bundle URLs. |
AI_TRAIN_THREADS |
2 |
Threads of the training worker only. |
AI_SHADOW_TIMEOUT |
8 |
Seconds to wait for the shadow model's answer in a dual classify call. |
AI_INFERENCE_MEM_LIMIT |
8g |
Memory limit of ai_inference. |
LOG_LEVEL |
INFO |
Log level of ai_inference, the feeder and mail_renderer. Not in .env.example. The Django level is app.log_level in settings.json. |
SVC_PREFIX |
empty | Prefix for every container_name (${SVC_PREFIX}suspicious), so several stacks can share a host. scripts/db-auth-migrate.sh honours it. Not in .env.example. |
DB_SERVER_ID |
1 |
MariaDB --server-id of the primary. The replica uses 2; change only when you run your own replication topology. Not in .env.example. |
See AI inference for the AI_* details.
2. settings.json — Application Configuration¶
Created by make init from settings-sample.json if absent.
2.1 Core Application¶
"app": {
"name": "suspicious",
"debug": false,
"secret_key": "CHANGE_ME",
"allowed_hosts": ["suspicious"],
"csrf_trusted_origins": ["https://suspicious.test"],
"timezone": "Europe/Paris",
"log_level": "INFO"
}
| Key | Description |
|---|---|
secret_key |
Django secret key. Generate with python3 -c "import secrets; print(secrets.token_urlsafe(64))". The boot-time schema check rejects placeholder/short keys; use 50+ random characters. |
debug |
false in production. Set true for local development — with false, Django redirects to HTTPS, so http://localhost:9020 is unreachable without Traefik. |
allowed_hosts |
Hostnames Django responds to (it always adds localhost and 127.0.0.1). For the Traefik/HTTPS path, include your DOMAIN_CORP or requests return DisallowedHost. |
csrf_trusted_origins |
Full origins allowed to POST (scheme + host), e.g. http://localhost:9020 for local and https://<DOMAIN_CORP> for the proxy. |
timezone |
Django timezone — affects timestamps and scheduled tasks |
log_level |
DEBUG / INFO / WARNING / ERROR |
2.2 Branding & UI¶
"branding": {
"company_name": "Test",
"contact_email": "suspicious@test.com",
"footer": "Your Group",
"intranet_link": "https://intranet.local",
"assets": {
"logo": "BASE64_LOGO",
"icon": "BASE64_ICON",
"banner": "BASE64_BANNER",
"signature": "BASE64_SIGNATURE"
}
}
Assets accept either a data:image/...;base64,... string or an https:// URL. These values drive the web UI appearance.
2.3 Features¶
"features": {
"dual_storage_write": false
}
| Key | Description |
|---|---|
dual_storage_write |
Write artifacts to both local storage and MinIO simultaneously |
2.4 URL Analysis¶
"url_analysis": {
"enabled": true,
"max_per_domain": 5,
"reuse_ttl_days": 7,
"shortener_domains": ["bit.ly", "tinyurl.com", "t.co", "goo.gl", "ow.ly"],
"suspicious_tlds": ["zip", "mov", "xyz", "top", "click"]
}
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
bool | true |
Kill-switch for the URL analysis planner. Set false to disable URL planning globally without removing configuration. |
max_per_domain |
int | 5 |
Maximum number of URLs analyzed per registered domain per case. Lowest-interestingness URLs beyond this cap are marked SKIPPED. |
reuse_ttl_days |
int | 7 |
Cross-case result reuse window in days. A URL whose canonical key matches a prior URL with a fresh AnalyzerReport within this window is marked REUSED instead of re-analyzed. |
shortener_domains |
list | ["bit.ly", "tinyurl.com", "t.co", "goo.gl", "ow.ly"] |
Known URL-shortener hostnames. Matching URLs receive a +30 interestingness boost because their final destination is unknown. Overrides the built-in list when non-empty. |
suspicious_tlds |
list | ["zip", "mov", "xyz", "top", "click"] |
TLDs associated with phishing and abuse. URLs on these TLDs receive a +20 interestingness boost. Overrides the built-in list when non-empty. |
2.5 Database¶
"database": {
"engine": "mysql",
"host": "db_suspicious",
"port": 3306,
"name": "db_suspicious",
"user": "suspicious",
"password": "password",
"root_password": "strongpassword",
"options": {
"ssl": false,
"connection_pooling": false,
"persistent_connections": false
}
}
host, name, and user must match the .env MYSQL_* values. Without Vault,
password here must equal .env MYSQL_PASSWORD — the backend reads the
database password from settings.json at boot when VAULT_ADDR is unset. A
mismatch gives Access denied for user 'suspicious'.
2.6 Storage (MinIO / RustFS)¶
"storage": {
"backend": "local",
"s3": {
"endpoint": "rustfs:9000",
"public_endpoint": "localhost:35002",
"access_key": "MINIO_ACCESS_KEY",
"secret_key": "MINIO_SECRET_KEY",
"secure": false,
"auto_create_bucket": true,
"media_bucket": "suspicious-media",
"web_bucket": "mail-submissions",
"fast_metadata": false
}
}
"backend": "local" (the default) stores files on the container filesystem and
does not require the RustFS service — handy for a minimal dev run. Set
"backend": "s3" to use object storage; then secret_key here must match the
RustFS credentials in .env (MINIO_ROOT_PASSWORD), and RustFS must be running.
web_bucket (default mail-submissions) is the one bucket that holds every
.eml / .msg uploaded through the web form, one key prefix per submission
(<user>-submission-<timestamp>-<id>/). Earlier versions created a bucket per
upload; those buckets keep working and are still read by the legacy scanner.
Do not point web_bucket at feeder_bucket: the feeder scanner expects a
_status.json in every prefix.
On object stores with a global bucket namespace (Google Cloud Storage), the default
name can already belong to someone else. Set web_bucket to a name unique to your
deployment instead of keeping mail-submissions.
endpoint is the Docker-internal host the app container uses for
put/get/bucket calls. Presigned URLs (mail-attachment links, uploaded
avatar photos) are opened directly by the browser, which can't resolve that
internal hostname — set public_endpoint to whatever host:port the browser
can reach. Omit it and it falls back to endpoint — existing
single-endpoint deployments are unaffected.
Behind Traefik (security-headers middleware in
traefik/dynamic/tls.yaml sets Content-Security-Policy: img-src 'self'
data:), a public_endpoint on a different origin than the app — a bare
host:port — gets silently dropped by the browser's CSP enforcement; no
console-visible network error, the <img> just never loads. Two ways to
avoid it:
- Same-origin (recommended, no CSP change): set public_endpoint to
whatever origin users actually type into the address bar — self in CSP
terms means the browser's origin, not DOMAIN_CORP. They're usually
the same, but not always: a cert issued for DOMAIN_CORP still lets a
browser load the app over https://localhost (or any other name that
resolves to the same box) if it's been told to tolerate the mismatch,
and CSP then evaluates against localhost, not DOMAIN_CORP. Check
Traefik's access.log (the Referer column on any request) if unsure
which origin is actually in play. Then set public_secure = true and
route S3 GETs through Traefik under that origin — see the
suspicious-storage router in tls.yaml, which matches PathPrefix on
the configured bucket names (S3 path-style URLs put the bucket name
first in the path, so no prefix-stripping is needed) and forwards to
rustfs:9000 with passHostHeader: true (required — S3 SigV4 signs the
host header, so whatever Host the browser sent when the URL was
signed must reach rustfs unchanged, or signature verification fails).
Keep that router's bucket-name list in sync with avatars_bucket /
media_bucket / feeder_bucket.
- No Traefik in front (plain-HTTP dev stack): there's no CSP at all in
that path, so a bare localhost:<published-9000-port> public_endpoint
works — see the /deploy-full-e2e flow in CLAUDE.md.
The client that signs public_endpoint URLs pins region to a fixed value
("us-east-1" unless storage.s3.region overrides it) instead of letting
the SDK auto-detect it. Auto-detection does a live bucket-location request
against the endpoint being signed for — and the whole point of
public_endpoint is that it's reachable from a browser, not necessarily
from wherever the Django/Celery process signing the URL runs, so that
request can't be relied on to succeed.
DB overrides settings.json at runtime. storage.s3 (like other
sections) is seeded into the RuntimeConfig table on first run and served
from there — Redis-cached — ahead of settings.json. Editing the file
after seed_config has already run does nothing until the matching DB
row is updated too (Settings page in the UI, or
RuntimeConfig.objects.filter(key="storage.s3") in a shell) and the cache
entry is invalidated (settings.config.invalidate_cache("storage.s3")).
Storage provider¶
storage.provider selects the object-store provider behind the backend's
ObjectStore interface. It is optional and defaults to s3, which covers
RustFS, MinIO and AWS S3 through boto3; the storage.s3 keys above are
unchanged. Resolution order: runtime config storage.provider, then the
STORAGE_PROVIDER environment variable, then s3. The provider is resolved
lazily on first use and cached, so a change takes effect after the backend
processes (suspicious, suspicious_celery, suspicious_celery_beat) are
recreated.
An unknown name raises StorageConfigError ("Unknown storage provider 'x';
registered providers: gcs, s3") on first use. It is also rejected when the process
starts: SuspiciousConfig.ready() raises ImproperlyConfigured ("Unknown
storage provider 'x' (from env STORAGE_PROVIDER)", or "from settings.json
storage.provider", listing the registered providers), so gunicorn, the Celery
worker and Celery beat refuse to boot, not only manage.py (which also reports
it as the system check storage.E001). A bad value set only through the
runtime config (DB) is not seen at startup and still fails on first use.
The legacy bucket-per-email tag scanner (tasp/cron/fetch_emails.py) needs a
provider that supports free-form bucket tags.
The store configuration, credentials included, is cached per process. After
rotating storage.s3.secret_key, recreate the backend processes; the 60 s
runtime-config cache no longer applies to it.
botocore honours HTTP_PROXY, HTTPS_PROXY and NO_PROXY; minio-py did not.
If a proxy is set in the container environment, list an external
storage.s3.endpoint in NO_PROXY or storage traffic goes through the proxy.
Google Cloud Storage (storage.gcs)¶
Set storage.provider to gcs to use Google Cloud Storage. The provider is
imported only when selected. The section is backend-only (the email feeder
never receives it).
Not usable end to end yet. Until the feeder and bucket-configuration
stages land, the email feeder still writes to RustFS via storage.s3, and the
backend still reads feeder_bucket and the avatar, media and feeder bucket
names from storage.s3. Keep a storage.s3 section when selecting gcs.
Bucket names are global on GCS. Set explicit, unique names and never keep the
defaults, which anyone could claim; the system check storage.W001 warns while
they are in use. suspicious-media and suspicious-avatars are set with
storage.s3.media_bucket and storage.s3.avatars_bucket. mail-previews and
analyzer-screenshots are only the Django settings MAIL_PREVIEW_BUCKET and
SCREENSHOT_BUCKET, which cannot be set from settings.json or the
environment today (known limitation): the warning for those two cannot be
cleared until a later stage adds configuration for them. Web submissions still create one
bucket per submission until a prefix mode is added (known limitation, on the
backlog).
| Key | Default | Meaning |
|---|---|---|
project |
ADC project | Project used for bucket creation and listing. |
location |
US multi-region | Location for buckets created by ensure_bucket. Google creates them in the US multi-region when this is unset, so set it explicitly. |
credentials |
adc |
adc (Application Default Credentials), service_account or anonymous (emulator only). |
service_account_json |
none | Secret. The service account key as a JSON string; required when credentials is service_account. A parse failure names the key and never echoes the content. |
api_endpoint |
Emulator or private endpoint, for example http://fake-gcs:4443. Use with credentials: anonymous for an emulator. Signed URLs use this host, so a private endpoint yields URLs browsers cannot reach, and the bearer token is sent to it: set it only to trusted endpoints. |
|
signing_service_account_email |
ADC email | Service account used for keyless signed URLs. The ADC credential is refreshed first, because a Compute credential reports default until refreshed. |
Signed URLs: a service account key signs offline. With ADC and no key, the
store signs through the IAM signBlob API (keyless) and refreshes expired
credentials on each use. With no signing identity (anonymous, or ADC that
cannot resolve an email) presigning fails with an invalid error and the
presign_put capability is not advertised. A presigned PUT created with a
content_type signs that header, so the uploader must send the identical
Content-Type.
IAM permissions:
- Object access on each bucket (for example roles/storage.objectAdmin).
objectAdmin alone is not enough: storage.buckets.get on each bucket is
also required (existence checks, labels).
- storage.buckets.list at project level for the health check. Under least
privilege without it, the store reports unhealthy.
- storage.buckets.create if the application creates buckets.
- roles/iam.serviceAccountTokenCreator on the signing service account, for
keyless signed URLs.
Proxy: the storage, IAM Credentials, OAuth2 and metadata hosts
(storage.googleapis.com, iamcredentials.googleapis.com,
oauth2.googleapis.com, metadata.google.internal) must be reachable
through the proxy or listed in NO_PROXY.
The secret storage.gcs.service_account_json can be seeded into Vault with
deployment/scripts/seed-vault-secrets.sh (GCS_SERVICE_ACCOUNT_JSON). It may
also be given as a JSON object instead of a string.
Labels: bucket labels are replaced as a whole set. Keys must be lowercase
letters, digits, _ or -, start with a letter, and be at most 63
characters. Values use the same characters and may be empty. At most 64 labels
are allowed. A label set that breaks this is rejected before any request is
sent.
Feeder fast metadata¶
fast_metadata (default false) controls the email-ingestion fast path. The
email-feeder always writes a per-email email.json (parsed addresses, subject,
body, headers, attachment sha256) next to each .eml. When fast_metadata is
true, the backend builds its EmailDataModel directly from that JSON —
sha256-verified against the on-disk attachments — instead of re-opening and
re-parsing the .eml. Any validation error, sha256 mismatch, or missing field
falls back to parse_email, so the worst case equals the legacy behavior.
| Value | Behavior |
|---|---|
false (default) |
Backend always re-parses the .eml (legacy). |
true |
Backend reads email.json; falls back to parse_email on any mismatch. |
Enable only after end-to-end parity (fast path vs parse_email produce
identical Mail rows) is green in staging.
2.7 Integrations¶
Cortex (required for analyzers)¶
"cortex": {
"url": "http://cortex:9001",
"api_key": "CHANGE_ME",
"webhook_secret": "CHANGE_ME_LONG_RANDOM_STRING",
"stale_job_timeout_seconds": 7200,
"analyzers": {
"header": "Mail_Header_Analyzer_1_0",
"ai": "AI_Mail_Analyzer",
"sandbox": "ThreatGridOnPrem_1_0",
"yara": "Yara_Boosted_3_2",
"file_info": "FileInfo_8_0"
}
}
| Key | Type | Default | Notes |
|---|---|---|---|
url |
string | — | Cortex API base URL. |
api_key |
string | — | Cortex API key. Generate via Cortex → Organization → User → API keys. |
webhook_secret |
string | "" (disabled) |
Bearer token Cortex sends to POST /api/cortex/webhook/. Compared via hmac.compare_digest to defeat timing oracles. Empty → webhook returns 503; configure Cortex with the matching shared secret. |
stale_job_timeout_seconds |
int | 7200 (2 h) |
CaseAnalyzerJob rows pending past this window are auto-failed by the fail_stale_jobs Celery task every 600 s, so cases never stall when Cortex drops a delivery or loses a job in a restart. Raise it only if an analyzer legitimately runs longer. |
analyzers |
object | — | Analyzer names must match those installed in your Cortex instance, exactly. ai is the exception: there is no Cortex AI analyzer any more. It only names the Analyzer row that carries the AI vote of the ai_model connector (its ledger row, its weight, the historical Cortex AI reports); absent, the name is AI_Mail_Analyzer. Keep it on an upgraded instance, see AI inference. |
The webhook view writes cortex_job_processed:<jobId> into the Redis cache with a 1 hour TTL on first delivery; duplicate Cortex retries short-circuit there. The cron fallback (update_ongoing_cases, every 300 s) covers any delivery that is missed entirely.
ChromaDB (AI vector store)¶
"chromadb": {
"url": "http://chromadb:8000",
"host": "chromadb",
"port": 8000,
"collection_name": "suspicious_mails",
"ssl_verify": false,
"token": ""
}
Holds the processed-mail embeddings (collection suspicious_mails) that the
ai_model connector's campaign detection searches for similar mails, and that
the chromadb connector prunes daily. collection_name must match between the
two, or the cleanup misses the collection.
For the AI inference stack (ai_inference, the ai_model connector and its url and token), see
AI inference: set up, deploy and operate.
token (secret) is sent as X-Chroma-Token; when empty the app falls back to
the CHROMA_TOKEN environment variable. Requests without it are rejected by
chromadb_proxy. See Internal service authentication.
TheHive (optional)¶
"thehive": {
"enabled": false,
"url": "https://thehive",
"api_key": "CHANGE_ME",
"verify_ssl": false,
"custom_field": "",
"email_sender": "",
"tags": "",
"certificate_path": "/app/cert.pem",
"user": "exemple@user.com"
}
Enable to automatically create cases/alerts in TheHive from Suspicious verdicts.
Watcher (optional)¶
"watcher": {
"enabled": false,
"url": "https://watcher",
"api_key": "CHANGE_ME",
"timeout": 10,
"verify_ssl": false
}
MISP (optional)¶
"misp": {
"default_tags": {
"tlp": "clear",
"pap": "clear",
"categories": ["MalSpam", "Phishing"]
},
"instances": {
"primary": {
"url": "http://misp",
"api_key": "CHANGE_ME",
"ssl_verify": false,
"ssl_ca_certs": "/etc/ssl/certs/ca-certificates.crt"
},
"secondary": {
"url": "https://secondary-misp",
"api_key": "CHANGE_ME",
"ssl_verify": false,
"ssl_ca_certs": "/etc/ssl/certs/ca-certificates.crt"
}
}
}
Allows pushing indicators of compromise to one or more MISP instances.
2.8 Authentication¶
"authentication": {
"oidc": {
"server_url": "https://oidc-server",
"client_id": "client-id",
"client_secret": "client-secret"
},
"ldap": {
"server_uri": "ldaps://ldap",
"bind_dn": "ou=Applications,ou=Gresources,o=Group",
"bind_password": "CHANGE_ME",
"base_dn": "ou=People,o=group",
"filter": "(&(mail=%(user)s)(Tpresent=true)(!(ou=admin)))",
"verify_ssl": true
}
}
Configure either OIDC or LDAP (or both).
LDAP TLS verification. verify_ssl defaults to true (OPT_X_TLS_DEMAND — full cert + hostname check). Setting it to false downgrades to OPT_X_TLS_NEVER and exposes bind credentials to any attacker on the network path; the boot logger emits a WARNING so the downgrade is visible in make logs. Use false only in local dev against a self-signed test LDAP, never in production. To support an internal CA, mount the CA bundle into the container and point libldap at it via the system trust store rather than disabling verification. This setting governs both LDAP code paths below.
Two independent LDAP query paths. This filter field (default (uid=%(user)s)) only controls the login search — django_auth_ldap's AUTH_LDAP_USER_SEARCH, used when a user authenticates. A second, separate query — the periodic profile sync in profiles/profiles_utils/ldap.py (Ldap.get_search_results, run every 10 min by the sync_user_profiles Celery beat task for every active user) — is not driven by this config field at all. It uses a filter hardcoded in that module, matching on mail, and further restricted by two attributes (Tpresent, TpreferredFirstName) that are not part of any standard LDAP schema (core/cosine/inetOrgPerson) — they assume a directory-specific schema extension. Against a plain-vanilla test LDAP server without that extension, this search silently returns zero results (not an error) for every user, so profile sync, CISO auto-detection, and directory-derived group membership will appear to do nothing. If you're standing up a local test directory (e.g. osixia/openldap) to exercise this path, either extend its schema to define those two attributes or drive Ldap.process_search_results() directly with hand-built search results in the shape python-ldap would return.
2.9 Company Domains¶
"domains": ["testgroup.com"]
Used to identify internal senders, auto-create users, and reduce false positives on domain matching.
2.10 Email & Notification¶
Controls SMTP settings, email content, links, social icons, and per-template logos.
"email": {
"api_base": "https://suspicious.test/api/",
"smtp": {
"server": "smtp.server.local",
"port": 25,
"username": "smtp_user",
"password": "smtp_password",
"tls": true,
"timeout": 10
},
"content": {
"footer": "Limited Distribution",
"team_name": "Your Cybersecurity Team",
"global_domain": "test.com",
"website": "https://www.test.com/en"
},
"links": {
"submissions": "https://suspicious.test/submissions",
"security_contact": "mailto:security@test.com",
"security_text": "security@test.com",
"inquiry": "mailto:inquiry@test.com",
"inquiry_text": "inquiry@test.com",
"glossary": "https://glossary.local"
},
"socials": {
"facebook": "https://fr-fr.facebook.com/test",
"twitter": "https://x.com/test",
"instagram": "https://www.instagram.com/test",
"linkedin": "https://www.linkedin.com/company/test",
"youtube": "https://www.youtube.com/test"
},
"templates": {
"acknowledgement": "Suspicious – Submission Registered",
"review": "Your submission n°{case_id} has been reviewed as: {result}",
"final": "SUSPICIOUS EMAIL ANALYSIS - Your analysis [{case_id}] is completed"
},
"logos": {
"company": "data:image/png;base64,BASE64_LOGO",
"acknowledge": "data:image/png;base64,BASE64_LOGO",
"final": "data:image/png;base64,BASE64_LOGO",
"challenge": "data:image/png;base64,BASE64_LOGO",
"modif": "data:image/png;base64,BASE64_LOGO"
}
}
Key fields¶
| Key | Description |
|---|---|
api_base |
Base URL used to build challenge/portal links inside emails |
smtp.tls |
Enable STARTTLS on the SMTP connection |
smtp.timeout |
Seconds to wait for the SMTP server before giving up (default 10); a failed send is retried by the smtp_notify connector |
content.team_name |
Team name shown in email body and footer |
content.global_domain |
Domain shown as a global link in the email footer |
links.security_contact |
mailto: URI for the security team — used in Dangerous verdict emails |
links.glossary |
Link to your cybersecurity glossary, shown in all email footers |
links.inquiry |
mailto: URI shown in the footer for general questions |
templates.* |
Subject line templates. {case_id} and {result} are interpolated at send time |
logos.* |
Per-template logos. Accept data:image/png;base64,..., data:image/svg+xml;base64,..., or https:// URLs. Outlook-safe rendering is handled automatically |
2.11 Observability¶
"observability": {
"opentelemetry": {
"enabled": false,
"service_name": "suspicious",
"otlp_endpoint": "http://tempo:4318"
}
}
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
bool | false |
Enable OTel tracing. Set true in production. |
service_name |
string | "suspicious" |
Service name shown in Tempo/Grafana. |
otlp_endpoint |
string | "http://tempo:4318" |
OTLP HTTP collector URL. Use http://tempo:4318 when Tempo runs in the monitoring Docker profile. |
Example production configuration:
{
"observability": {
"opentelemetry": {
"enabled": true,
"service_name": "suspicious-prod",
"otlp_endpoint": "http://tempo:4318"
}
}
}
To collect traces, set observability.opentelemetry.enabled to true (above)
and start the Tempo + Grafana stack manually from its compose files under
deployment/docker/monitoring/ (Grafana serves on port 3000). The old
make monitor-up target has been removed.
3. email-feeder/config.json — Email Ingestion Service¶
Created by make init from config-sample.json if absent.
3.1 Mail Connectors (IMAP / IMAPS)¶
"mail-connectors": {
"imap": {
"imap-dev": {
"enable": true,
"host": "localhost",
"port": 3143,
"login": "imap_user",
"password": "imap_password",
"mailbox_to_monitor": "INBOX"
}
},
"imaps": {
"imaps-dev": {
"enable": false,
"host": "localhost",
"port": 3993,
"login": "imap_user",
"password": "imap_password",
"certfile": "/path/to/dev/certfile.pem",
"keyfile": "/path/to/dev/keyfile.pem",
"rootcafile": "/path/to/dev/rootcafile.pem",
"mailbox_to_monitor": "INBOX"
},
"imaps-prod": {
"enable": false,
"host": "localhost",
"port": 3993,
"login": "imap_user",
"password": "imap_password",
"certfile": "/path/to/prod/certfile.pem",
"keyfile": "/path/to/prod/keyfile.pem",
"rootcafile": "/path/to/prod/rootcafile.pem",
"mailbox_to_monitor": "INBOX"
}
}
}
You can define as many named connectors as needed. Set "enable": false to
deactivate a connector without removing it.
The feeder needs at least one enabled connector. With every connector disabled it logs
No mailboxes were successfully set up … Exitingand the container restart-loops. This is expected when you run the stack without a mailbox to poll; it does not affect the backend or UI.
| Key | Description |
|---|---|
host / port |
IMAP(S) server address and port |
login / password |
Mailbox credentials |
certfile / keyfile / rootcafile |
Client certificate paths (IMAPS only) |
mailbox_to_monitor |
Folder to poll — usually INBOX |
3.2 Working Directory & Polling¶
"working-path": "/tmp/suspicious",
"timer-inbox-emails": 10
| Key | Description |
|---|---|
working-path |
Temporary directory for fetched emails, attachments, and processing queue |
timer-inbox-emails |
Polling interval in seconds |
3.3 Polling mode (imap-idle)¶
"imap-idle": true
| Key | Default | Description |
|---|---|---|
imap-idle |
true |
With exactly one enabled IDLE-capable mailbox, the feeder blocks in IMAP IDLE and fetches on arrival instead of sleeping timer-inbox-emails seconds between polls. Set false to force interval polling everywhere. With several enabled mailboxes it always polls. |
3.4 Storage and outgoing mail (from the backend)¶
The feeder has no s3 or mail block in config.json (config-sample.json
does not ship them). When BACKEND_URL and FEEDER_API_TOKEN are set in
deployment/.env, it fetches them at boot from GET /api/config/feeder/ (see
Config authority) and merges
them with the local mail-connectors, working-path, timer-inbox-emails and
imap-idle. They are configured once, in settings.json:
| Feeder uses | Source in settings.json |
|---|---|
S3 endpoint, access_key, secret_key, secure |
storage.s3.* |
SMTP server, port, username, password, tls |
email.smtp.* |
| Footer, team name, domain, website | email.content.footer, team_name, global_domain, website |
| Submissions portal, glossary, inquiry and security contacts | email.links.submissions, glossary, inquiry, inquiry_text, security_contact, security_text |
| Social links, logos | email.socials, email.logos |
The mail settings drive the acknowledgement sent when a forwarded message cannot be processed; the keys are documented in §2.10.
If either variable is empty, the feeder reads the whole configuration from the
local config.json instead, in which case it needs an s3 block (endpoint,
access_key, secret_key, secure) and a mail block (the keys of the
email.* rows above, flattened). On a backend failure it falls back to the
last-good cache (.config-cache.json); with no cache it exits.
Runtime config + secrets (DB + Vault)¶
In production, configuration is split across three tiers; settings.json stays
the dev/CI fallback for both.
- Non-secret runtime config now lives in the database
(
settings.RuntimeConfig). It is seeded fromsettings.jsonbypython manage.py seed_config(run automatically bymake deploy), editable from the Django admin, and read in code viasettings.config.get_config/settings.config.get_section. When the DB has no seeded value, the accessor falls back tosettings.json. - Secrets (API keys, passwords, the Django secret key) live in HashiCorp
Vault (KV v2 at
suspicious/<dotted-key>, fieldvalue), read viasuspicious.secrets.get_secretusing AppRole auth. WhenVAULT_ADDRis unset, secrets fall back tosettings.json/settings.ci.json. LeaveVAULT_ADDRunset for local development — Compose defaults it to empty, so the backend uses thesettings.jsonfallback. Set it only once Vault is provisioned; otherwise the backend fails fast at boot trying to reach Vault. - Schema caveat:
settings.jsonmust still keep schema-valid dummies forapp.secret_keyanddatabase.passwordeven under Vault — the boot-time schema check requires them, and the real values are overlaid from Vault whenVAULT_ADDRis set.
See deployment/VAULT.md for the secret map, prod bring-up order, and the dev/CI path.
Config authority (shared config across services)¶
The config authority is a scoped HTTP endpoint that lets backend services pull their effective configuration at boot instead of maintaining separate copies of shared values.
Scopes¶
| Scope | What it holds |
|---|---|
shared |
storage.s3, all email.* sections, branding — values used by more than one service. Never requested directly; always merged into a real scope. |
backend |
Integration settings (Cortex, TheHive, MISP, ChromaDB, LDAP/OIDC) and auth config. |
feeder |
No feeder-specific keys yet; consumes shared (S3, SMTP, branding). |
manage.py seed_config seeds each settings.json section into its scope.
Endpoint¶
GET /api/config/{scope}/
Returns the effective config for scope: the shared sections merged with the
scope's own sections. Non-secret fields come from the DB; secret leaves are
overlaid from Vault. Response always carries Cache-Control: no-store, is
throttled at 30 req/min, and is never written to application logs.
Auth: Knox token whose user belongs to the Django group named after the scope. Superusers may read any scope.
# Example — fetch the feeder scope (requires TLS via Traefik in production)
curl -s -H "Authorization: Token <feeder-token>" \
https://suspicious.example.com/api/config/feeder/
Provisioning¶
# Seed config sections into the DB (already run by `make deploy`)
docker compose exec suspicious python manage.py seed_config
# Create the feeder service account and print its Knox token (run once)
docker compose exec suspicious python manage.py create_service_token feeder
create_service_token feeder creates a svc-feeder user in the feeder
group and prints a Knox token. Paste it into deployment/.env as
FEEDER_API_TOKEN.
Effect on the email feeder¶
The feeder now fetches S3 credentials, SMTP settings, and branding from
GET /api/config/feeder/ at boot when BACKEND_URL and FEEDER_API_TOKEN
are set. It writes a last-good cache (.config-cache.json, mode 0600) and
falls back to it on a backend blip; with no cache and no backend it exits
immediately. As a result, email-feeder/config.json holds only
mail-connectors, working-path, timer-inbox-emails and imap-idle
(see §3).
Internal service authentication¶
Redis, Elasticsearch and ChromaDB refuse anonymous clients. All secrets are
random hex generated by make init (scripts/gen-service-secrets.sh; it only
fills missing values and never overwrites). Characters are limited to
[A-Za-z0-9_.-].
| Variable | File | Used by |
|---|---|---|
REDIS_BROKER_PASSWORD |
.env |
redis_broker, Django/Celery (Celery broker) |
REDIS_CACHE_PASSWORD |
.env |
redis_cache, Django cache |
ES_SUSPICIOUS_USER / ES_SUSPICIOUS_PASSWORD |
.env |
Django/Celery (least-privilege suspicious_app role on suspicious-*) |
ES_CORTEX_USER / ES_CORTEX_PASSWORD |
.env |
Cortex (cortex_app role on cortex*) |
ELASTIC_PASSWORD |
.env.data (mode 600) |
elasticsearch and scripts/es-security-bootstrap.sh only, never the application containers |
CHROMA_TOKEN |
.env |
chromadb_proxy and the app |
Resolution order in Django: the environment variable first, then the
matching secret-store key (Vault / settings.json, e.g. redis.broker_password,
integrations.chromadb.token). A missing Redis password fails fast at startup.
Why a proxy for ChromaDB: the pinned Chroma release has no built-in
token authentication, so chromadb sits on an internal-only network and
chromadb_proxy (nginx) checks the X-Chroma-Token header before forwarding.
Redis cache timeouts: the Django cache connection uses
socket_timeout and socket_connect_timeout of 3 s (REDIS_CACHE_OPTIONS in
suspicious/settings.py, not configurable). The cache only serves short
add/get/set/delete calls (per-case reconcile lock, feeder fetch lock,
webhook jobId dedup), so a hung or very slow redis_cache fails fast with a
cache error after 3 s instead of blocking a request or worker. Look at
redis_cache first when you see those errors.
Limitations: traffic between containers on the internal network is plain HTTP/TCP (no TLS); the credentials protect against anonymous or compromised-neighbour access, not against sniffing on the Docker host. The Helm chart is not yet configured for service authentication.
make db-auth-check verifies that each service rejects anonymous access and
accepts the configured credential. Existing deployments upgrade with
make db-auth-migrate (see Deploy & Rollback).
Best Practices¶
- Never commit secrets — use environment variable injection, Docker secrets, or a secrets manager in production.
- Enable SSL/TLS everywhere in production — IMAPS connectors, database, Cortex, MISP, TheHive.
- Customize before first run — set
secret_key,allowed_hosts,csrf_trusted_origins, and branding assets before exposing the stack. - Back up before changing credentials — modifying
MYSQL_*after first run requires dropping the database volume, which erases all data. - Run
make initbeforemake upon every fresh deployment to catch missing files and permission issues.
Launch¶
Once .env, settings.json, and config.json are complete, build and start,
then initialise the database on the first run (the backend stays unhealthy
until migrations are applied):
docker compose build suspicious suspicious_ui
make up
make migrate
make seed-config
make createsuperuser
The full stack starts: web UI, API, database, email-feeder, Cortex, RustFS,
Elasticsearch, ChromaDB, Traefik, and Vault. make seed-config loads the
non-secret runtime config into the database (see Runtime config + secrets).
See INSTALL.md for the step-by-step walkthrough and verification.
Related Documentation¶
| Document | Description |
|---|---|
| INSTALL.md | Full installation and deployment instructions |
| deployment/README.md | Deployment-specific instructions |
| deployment/VAULT.md | Vault secrets + DB runtime-config runbook |
| README.md | Project overview, features, and usage |
| CONTRIBUTING.md | Development and contribution guidelines |