Skip to content

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 init checks 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.json database.password must equal .env MYSQL_PASSWORD (no Vault) — §2.5
settings.json app.debug true for plain HTTP on localhost; false forces HTTPS via Traefik — §2.1
settings.json app.allowed_hosts include DOMAIN_CORP, or the Traefik path returns DisallowedHost — §2.1
.env CORTEX_PATH absolute 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_PASSWORD must match settings.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_PATH must 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 with invalid 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 Google 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 … Exiting and 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 from settings.json by python manage.py seed_config (run automatically by make deploy), editable from the Django admin, and read in code via settings.config.get_config / settings.config.get_section. When the DB has no seeded value, the accessor falls back to settings.json.
  • Secrets (API keys, passwords, the Django secret key) live in HashiCorp Vault (KV v2 at suspicious/<dotted-key>, field value), read via suspicious.secrets.get_secret using AppRole auth. When VAULT_ADDR is unset, secrets fall back to settings.json / settings.ci.json. Leave VAULT_ADDR unset for local development — Compose defaults it to empty, so the backend uses the settings.json fallback. Set it only once Vault is provisioned; otherwise the backend fails fast at boot trying to reach Vault.
  • Schema caveat: settings.json must still keep schema-valid dummies for app.secret_key and database.password even under Vault — the boot-time schema check requires them, and the real values are overlaid from Vault when VAULT_ADDR is 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 init before make up on 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.


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