Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Configuration

CSM is configured via /etc/csm/csm.yaml, with --config <path> to override. Legacy installs that only have /opt/csm/csm.yaml keep working; packaged upgrades migrate that file into /etc/csm/csm.yaml and leave the old path as a compatibility link. Optional drop-in fragments under /etc/csm/conf.d/*.yaml are merged on top of the main file at startup; see conf.d drop-ins below.

Platform & Web Server

CSM auto-detects the host OS (Ubuntu, Debian, AlmaLinux, Rocky, RHEL, CloudLinux), control panel (cPanel, Plesk, DirectAdmin, or none), and web server (Apache, Nginx, LiteSpeed, or none) at daemon startup. The detected platform is logged as:

[2026-04-10 08:13:37] platform: os=ubuntu/24.04 panel=none webserver=nginx

The daemon then chooses the correct log paths, config candidates, and check set without any configuration from you. Verify with:

journalctl -u csm.service | grep platform:

Web server overrides

For hosts with a custom layout (reverse proxy, non-standard package locations, chroot), add a web_server: section to csm.yaml. Every field is optional – anything left blank falls back to auto-detection.

web_server:
  type: "nginx"                          # apache | nginx | litespeed -- overrides auto-detect
  config_dir: "/etc/nginx"               # for info/diagnostics only
  access_logs:                           # tried in order until one exists
    - "/var/log/nginx/access.log"
    - "/srv/logs/nginx/access.log"
  error_logs:                            # used by ModSecurity deny watcher
    - "/var/log/nginx/error.log"
  modsec_audit_logs:
    - "/var/log/nginx/modsec_audit.log"

modsec_error_log (legacy single-path override) is still honored and takes precedence over web_server.error_logs for the ModSecurity watcher only:

modsec_error_log: "/opt/myapp/logs/modsec_audit.log"

Account roots (plain Linux web-scan coverage)

By default, the account-scan based checks (perf_error_logs, perf_wp_config, perf_wp_transients, and related) iterate /home/*/public_html which is the cPanel layout. On plain Ubuntu / AlmaLinux with Nginx or Apache, point CSM at your actual web roots:

account_roots:
  - "/var/www/*/public"            # e.g. Laravel/Symfony sites
  - "/srv/http/*"                  # Arch / generic layouts
  - "/home/*/public_html"          # add if you also have cPanel-style accounts

Each entry is a glob pattern expanded at scan time. Non-existent matches are silently dropped. If account_roots is empty and CSM is not on a cPanel host, the account-scan checks return no findings (they run but find nothing, which is the correct behavior for a plain-Linux host with no configured web roots).

Today, three checks consume this: perf_error_logs, perf_wp_config, perf_wp_transients. The remaining account-scan checks (WordPress core integrity, phishing kit detection, htaccess tampering, fileindex, etc.) still assume the cPanel /home/*/public_html layout and will be migrated in a follow-up release.

Minimal Config

hostname: "csm.example.com"

alerts:
  email:
    enabled: true
    to: ["admin@example.com"]
    disabled_checks: []                   # optional: suppress these checks from email only
    smtp: "localhost:25"

webui:
  enabled: true
  listen: "0.0.0.0:9443"
  auth_token: "your-secret-token"

infra_ips: ["10.0.0.0/8"]

Full Reference

hostname: "csm.example.com"

# --- Alerts ---
alerts:
  email:
    enabled: true
    to: ["admin@example.com"]
    from: "csm@csm.example.com"
    smtp: "localhost:25"
    disabled_checks: []                 # check names to keep in web/history but exclude from email
  webhook:
    enabled: false
    url: ""
    type: "slack"                       # slack, discord, generic, phpanel
    hmac_secret: ""                     # phpanel webhook signing secret
    hmac_secret_env: ""                 # env var containing phpanel signing secret
    per_finding: false                  # phpanel sends one signed POST per finding
  heartbeat:
    enabled: false
    url: ""                             # healthchecks.io, cronitor, dead man's switch
  max_per_hour: 10                      # default: 10
  audit_log:                            # SIEM-friendly per-finding stream
    file:
      enabled: false
      path: /var/log/csm/audit.jsonl    # default; logrotate fragment ships with the package
    syslog:
      enabled: false
      network: udp                      # udp | tcp | unix | unixgram | tls
      address: 127.0.0.1:514            # host:port, or filesystem path for unix variants
      facility: local0                  # default: local0
      tls_ca: ""                        # optional CA cert for tls transport

# --- Integrity ---
integrity:
  binary_hash: ""                       # auto-populated by install/rehash
  config_hash: ""                       # auto-populated by install/rehash
  confd_hash: ""                        # auto-populated by install/rehash
  immutable: false                      # prevent config changes at runtime

# --- Thresholds ---
thresholds:
  mail_queue_warn: 500                  # default: 500
  mail_queue_crit: 2000                 # default: 2000
  state_expiry_hours: 24                # default: 24
  deep_scan_interval_min: 60            # minutes between deep scans (default: 60)
  wp_core_check_interval_min: 60        # WordPress core checksum interval (default: 60)
  webshell_scan_interval_min: 30        # webshell scan interval (default: 30)
  filesystem_scan_interval_min: 30      # filesystem scan interval (default: 30)
  multi_ip_login_threshold: 3           # IPs per account before alert (default: 3)
  multi_ip_login_window_min: 60         # time window for multi-IP check (default: 60)
  cred_stuffing_distinct_accounts: 5    # failed accounts from one IP before credential_stuffing (default: 5)
  plugin_check_interval_min: 1440       # WordPress plugin check interval (default: 1440)
  brute_force_window: 5000              # failed auth attempts window (default: 5000)
  domlog_max_files: 500                 # per-domain access logs per WP brute-force scan (default: 500)
  domlog_tail_lines: 500                # trailing lines tailed from each domlog per scan (default: 500)
  domlog_max_age_min: 30                # skip per-domain access logs untouched in this many minutes (default: 30)
  mail_log_tail_lines: 500              # trailing lines of /var/log/exim_mainlog read by the mail-per-account scanner (default: 500)
  syslog_messages_tail_lines: 200       # trailing lines of /var/log/messages read by the FTP login scanner (default: 200)
  account_scan_max_files: 10000         # account and mail-domain paths per scanner cycle (default: 10000)
  # If this cap clips /home/<account>/ paths, account_scan_truncated names the affected account.
  crontab_base64_blob_max_bytes: 16384  # encoded bytes per crontab base64 candidate before decoded-content matching; must be a multiple of 4 (default: 16384)

  # HTTP request flood, User-Agent spoof, and distributed HTTP detection.
  # These detectors scan the same per-vhost access-log stream as the WP
  # brute-force scanner; no extra log tailer is needed.
  #
  # http_flood_threshold: minimum per-IP request count inside the window
  # that emits http_request_flood. 0 disables the detector. The detector
  # ships disabled so operators can sample local baseline traffic first.
  # Adjust up for CDNs or CGNAT-heavy visitor pools before enabling.
  http_flood_threshold: 0              # 0 = disabled; set after sampling baseline traffic
  http_flood_window_min: 5             # rate window in minutes (default: 5)

  # http_ua_spoof_threshold: per-IP per-window count for non-browser UA
  # kinds before http_ua_spoof fires. Claimed search-engine bots (Googlebot,
  # Bingbot, Applebot) that fail reverse-DNS confirmation fire regardless of
  # this threshold once the rDNS cache confirms the IP is not the real bot.
  http_ua_spoof_threshold: 30          # default: 30

  # http_distributed_min_ips: distinct already-abusive source IPs that hit
  # the same vhost in one scan window before a per-vhost distributed flood
  # finding fires. 0 disables the rollup for existing configs that do not
  # opt in.
  http_distributed_min_ips: 10         # sample setting; omit or set 0 to disable

  # These three opt-in flags extend UA spoof detection to additional UA
  # classes. Leave disabled on busy shared hosts; scripting-language agents
  # and headless browsers appear on many legitimate monitoring stacks.
  http_ua_scripting_enabled: false     # flag curl/wget/python-requests/Go-http style UAs
  http_ua_headless_enabled: false      # flag Puppeteer/Playwright/PhantomJS UAs
  http_ua_empty_enabled: false         # flag requests with no UA at all

  # SMTP brute-force tracker (Exim mainlog, dovecot SASL on submission ports)
  smtp_bruteforce_threshold: 5            # per-IP failed auths before block (default: 5)
  smtp_bruteforce_window_min: 10          # sliding window in minutes (default: 10)
  smtp_bruteforce_suppress_min: 60        # cooldown between repeat findings (default: 60)
  smtp_bruteforce_subnet_threshold: 8     # unique IPs per /24 before subnet block (default: 8)
  smtp_account_spray_threshold: 12        # unique IPs targeting one mailbox before visibility finding (default: 12)
  smtp_bruteforce_max_tracked: 20000      # soft cap on tracked entries; oldest evicted (default: 20000)

  # SMTP probe-abuse tracker (raw connect-rate per IP; catches scanners that
  # never reach AUTH). Threshold sized well above any legitimate MUA usage.
  smtp_probe_threshold: 100               # per-IP connects before block (default: 100; explicit 0 disables)
  smtp_probe_window_min: 5                # sliding window in minutes (default: 5)
  smtp_probe_suppress_min: 60             # cooldown between repeat findings (default: 60)
  smtp_probe_max_tracked: 20000           # soft cap on tracked entries; oldest evicted (default: 20000)

  # Mail brute-force tracker (IMAP/POP3/ManageSieve via mail_logs source)
  mail_bruteforce_threshold: 5            # per-IP failed auths before block (default: 5)
  mail_bruteforce_window_min: 10          # sliding window in minutes (default: 10)
  mail_bruteforce_suppress_min: 60        # cooldown between repeat findings (default: 60)
  mail_bruteforce_subnet_threshold: 8     # unique IPs per /24 before subnet block (default: 8)
  mail_account_spray_threshold: 12        # unique IPs targeting one mailbox before visibility finding (default: 12)
  mail_bruteforce_max_tracked: 20000      # soft cap on tracked entries; oldest evicted (default: 20000)
  mail_brute_account_key: "builtin:dovecot-user" # builtin:dovecot-user | builtin:postfix-sasl | regex:<capture>
  modsec_escalation_hits: 3          # denies from one IP before ModSecurity escalation (default: 3)
  modsec_escalation_window_min: 10   # ModSecurity escalation window in minutes (default: 10)

# --- Web server overrides ---
# Leave these empty to use auto-detected paths for the running platform.
web_server:
  # Override the per-vhost access-log glob patterns. Empty uses the
  # auto-detected default for the panel (cPanel, Plesk, DirectAdmin,
  # bare Apache, or bare Nginx).
  domlog_globs: []
  # IPs or CIDRs whose X-Forwarded-For header is trusted for client-IP
  # extraction. Leave empty to ignore XFF and use RemoteIP as-is.
  trusted_proxies: []

# --- Infrastructure ---
infra_ips: []                           # management IPs/CIDRs/hostnames - never blocked

# --- Mail Logs ---
# Packaged releases include journald support. Custom builds need
# `make JOURNAL=1 build-yara` before `source: journal` can be selected.
mail_logs:
  source: auto                          # auto | file | journal
  file: ""                              # optional path override for file source
  units: ["postfix", "dovecot"]         # journal units for source=journal or auto fallback

# --- State ---
state_path: "/var/lib/csm/state"        # bbolt DB and state files

# --- Suppressions ---
suppressions:
  upcp_window_start: "00:30"            # cPanel nightly update window start
  upcp_window_end: "02:00"              # cPanel nightly update window end
  known_api_tokens: []                  # API tokens to ignore in auth logs (e.g. ["phclient"])
  ignore_paths:                         # glob patterns to skip in filesystem scans
    - "*/cache/*"
    - "*/vendor/*"
  suppress_webmail_alerts: true         # don't alert on webmail logins
  suppress_cpanel_login_alerts: false   # don't alert on cPanel direct logins
  suppress_blocked_alerts: true         # don't alert on IPs that were auto-blocked
  trusted_countries: ["RO"]             # ISO 3166-1 alpha-2 - suppress cPanel login alerts from these

# --- Auto-Response ---
auto_response:
  enabled: false
  kill_processes: false                 # kill malicious processes
  quarantine_files: false               # move malware to quarantine
  block_ips: false                      # block attacker IPs via firewall
  block_expiry: "24h"                   # duration for temp blocks (e.g. "24h", "12h")
  max_blocks_per_hour: 50               # per-IP blocks per hour; 0/omitted uses default
  enforce_permissions: false            # auto-chmod 644 world/group-writable PHP files
  block_cpanel_logins: false            # block IPs on cPanel/webmail/FTP/API thresholded brute findings (multi-IP login, webmail/API brute, FTP brute). Single direct cPanel form logins stay audit-only regardless of this flag.
  netblock: false                       # auto-block IPv4 /24 or IPv6 /64 subnets
  netblock_threshold: 3                 # IPs from same IPv4 /24 or IPv6 /64 before subnet block
  permblock: false                      # promote temp blocks to permanent
  permblock_count: 4                    # temp blocks before promotion
  permblock_interval: "24h"             # window for counting temp blocks
  clean_database: false                 # auto-drop confirmed malicious DB objects after backup
  clean_htaccess: false                 # auto-clean .htaccess directives flagged by hardened detectors (backups under /opt/csm/quarantine/pre_clean/)
  disable_enforce_af_alg: false         # suspend periodic AF_ALG hardening re-assertion
  copy_fail_kill_process: false         # kill processes caught opening AF_ALG sockets via the live listener
  dry_run: true                         # safe default; logs intended IP blocks without mutating nftables
  verdict_callback:
    enabled: false                      # call panel before each auto-block
    url: ""                             # POST target for verdict requests
    hmac_secret: ""                     # signing secret, or use hmac_secret_env
    hmac_secret_env: ""                 # env var read at call time
    allow_unsigned: false               # true only for staged unsigned rollouts
    require_response_signature: true    # reject unsigned callback replies
    timeout_sec: 2                      # callback request timeout

  # PHP-relay auto-freeze. Off by default; only kicks in on cPanel hosts
  # where email_protection.php_relay.enabled is true. dry_run defaults to
  # true even when freeze is true, so an operator who enables freeze
  # without thinking gets a dry-run rather than a live exim -Mf storm.
  # Override at runtime with `csm phprelay dry-run on|off|reset`.
  php_relay:
    freeze: false                       # opt in to wire the exim -Mf hook into the alert pipeline
    dry_run: true                       # safe default; flip with `csm phprelay dry-run off [--persist]`
    max_actions_per_minute: 60          # rolling 60s cap on exim -Mf invocations

# --- Detection ---
detection:
  # db_object_scanning is tri-state: omit for the default (on),
  # `false` to explicitly disable. When off, the MySQL persistence
  # scanner emits no findings; the manual `csm db-clean --drop-object`
  # CLI keeps working for operator-driven cleanup.
  # db_object_scanning: true
  db_object_allowlist: []               # entries: <account>:<schema>:<type>:<name> -- suppresses db_unexpected_* warnings only
  admin_overlap_min_accounts: 2         # raise only if routine shared-admin accounts are expected on this host
  admin_overlap_trusted_emails: []       # exact reviewed admin emails that may manage multiple cPanel accounts
  admin_overlap_trusted_domains: []      # exact reviewed email domains for developer or reseller admin accounts
  # rescan_on_signature_update: true    # tri-state; omit for default-on, false to disable retroactive sweeps
  af_alg_backend: "auto"                # auto | bpf | auditd | none
  connection_tracker_backend: "auto"    # auto | bpf | legacy | none
  connection_poll_interval: 30s         # legacy connection tracker interval
  exec_monitor_backend: "auto"          # auto | bpf | legacy | none
  exec_monitor_poll_interval: 30m       # legacy process monitor interval
  sensitive_files_backend: "auto"       # auto | bpf | legacy | none
  sensitive_files_poll_interval: 5m     # sensitive-file poll/watchset refresh interval
  direct_smtp_egress:
    enabled: false                      # detect non-MTA local processes opening outbound SMTP
    backend: "auto"                     # auto | bpf | legacy | none
    dry_run: true                       # safe default for detector-scoped action
    ports: [25, 465, 587]               # destination ports to inspect

# --- BPF Enforcement ---
bpf_enforcement:
  enabled: false                        # master switch for in-kernel denial
  dry_run: true                         # log intended denials, allow the connect
  direct_smtp_egress: false             # gate enforcement on direct SMTP egress matches
  verdict_callback: false               # userspace advisory callback after the BPF decision

# --- Challenge Pages ---
challenge:
  enabled: false                        # enable PoW challenge pages instead of hard block
  listen_addr: 127.0.0.1                # bind address; use 0.0.0.0 for public direct redirects
  listen_port: 8439                     # port for challenge server; must fit the TCP port range
  tls_cert: ""                          # optional HTTPS cert for direct/public challenge listener
  tls_key: ""                           # optional HTTPS key for direct/public challenge listener
  public_url: ""                        # required by webserver-integration, e.g. https://host:8439/challenge
  secret: ""                            # HMAC secret for tokens (auto-generated if empty)
  difficulty: 2                         # SHA-256 proof-of-work difficulty 0-5 (default: 2)
  trusted_proxies: []                   # IPs/CIDRs allowed to supply X-Forwarded-For
  port_gate:
    enabled: false                      # nftables gate for non-loopback challenge listener
  captcha_fallback:                     # widget for JS-disabled visitors (default off)
    provider: ""                        # "turnstile" | "hcaptcha" | "" (off)
    site_key: ""                        # public key embedded in the widget
    secret_key: ""                      # verified server-side
    timeout: 10s
  verified_session:                     # signed-cookie bypass for authenticated operators
    enabled: false
    cookie_name: csm_admin_session
    ttl: 4h
    admin_secret: ""                    # POST'd to /challenge/admin-token to mint cookie
  verified_crawlers:                    # reverse-DNS forward-confirm for search crawlers
    enabled: false
    providers: []                       # names: googlebot | bingbot
    cache_ttl: 15m

# --- PHP Shield ---
php_shield:
  enabled: false                        # watch the PHP Shield event log for alerts

# --- Reputation ---
reputation:
  abuseipdb_key: ""                     # AbuseIPDB API key for IP reputation lookups
  whitelist: []                         # IPs to never flag as malicious
  # Async PTR + forward-A verification for IPs that claim search-engine
  # bot UAs (Googlebot, Bingbot, Applebot). When an IP claims a bot UA
  # but reverse DNS does not confirm it, the request counts toward
  # http_ua_spoof. Transient DNS lookup failures fail open and are
  # retried later. Set false only if your resolver is unreliable. See
  # docs/src/auto-response.md for the always-block behavior.
  bot_verify_enabled: true              # default: true
  rspamd:
    enabled: false                      # include rspamd rolling history in IP reputation
    url: "http://127.0.0.1:11334"       # rspamd controller URL
    token: ""                           # controller password, or use token_env
    token_env: ""                       # env var read at query time
  upstream:
    enabled: false                      # include panel-side threat-intel cache scores
    url: ""                             # HTTPS base URL; HTTP only allowed for loopback
    token: ""                           # bearer token, or use token_env
    token_env: ""                       # env var read at query time
    cache_ttl_min: 15                   # local cache TTL for upstream scores
    timeout_sec: 5                      # upstream request timeout
  report:
    enabled: false                      # opt-in abuse report delivery; restart required
    classes: []                         # bruteforce | php_relay | credential_stuffing | bad_asn_egress
    spool_path: ""                      # default: <state_path>/abuse_reports.db
    spool_max: 10000                    # max queued reports per target
    targets:
      - name: ""                        # stable target name
        url: ""                         # HTTPS collector URL; HTTP only allowed for loopback
        transport: "hmac"               # hmac | ed25519
        node_id: ""                     # sender node ID
        key_id: ""                      # receiver key ID
        key_env: ""                     # HMAC secret or Ed25519 private key env var
        token_env: ""                   # optional bearer token env var for HMAC targets
  central:
    enabled: false                      # opt-in central scored-set consume; restart required
    set_url: ""                         # HTTPS scored-set endpoint; HTTP only for loopback
    pubkey_env: ""                      # env var with Ed25519 public key hex
    refresh_interval: 6h                # pull interval; default 6h
    action: "challenge"                 # off | challenge | block_if_local_corroborated
    block_threshold: 80                 # score needed before local corroboration can block

# --- Signatures ---
signatures:
  rules_dir: "/opt/csm/rules"           # YAML signature rules directory
  update_url: ""                        # remote URL to fetch rule updates
  auto_update: false                    # auto-download rules on schedule
  update_interval: ""                   # how often to check (e.g. "24h")
  signing_key: ""                       # required for any remote rule update path; 64-char hex Ed25519 public key
  yara_forge:
    enabled: false                      # auto-fetch YARA Forge community rules
    tier: "core"                        # "core", "extended", "full" (default: "core")
    update_interval: "168h"             # how often to check for updates (default: weekly)
    download_url: ""                    # signed ZIP URL/template; supports {tier} and {version}
  disabled_rules: []                    # YARA rule names to exclude from Forge downloads
  # yara_worker_enabled: true           # tri-state: omit for the default (on), `false` to explicitly disable

# signatures.signing_key is mandatory whenever either signatures.update_url
# is set or signatures.yara_forge.enabled is true. It must be the hex
# Ed25519 public key used to verify detached .sig files for rule bundles.
# Remote update URLs must use HTTP or HTTPS and must not point at localhost,
# loopback, link-local, unspecified, or RFC1918 / ULA private addresses.
#
# YARA Forge upstream GitHub releases do not publish CSM detached signatures.
# To enable automatic Forge updates, mirror the ZIPs, sign each ZIP, publish
# the signature at the ZIP URL plus .sig, and set yara_forge.download_url to
# that signed mirror. Otherwise leave update_url empty and yara_forge.enabled
# false.

# --- Web UI ---
webui:
  enabled: true
  listen: "0.0.0.0:9443"               # address:port for HTTPS server
  auth_token: ""                        # Bearer/cookie auth token (auto-generated on install)
  tokens: []                            # optional scoped tokens: name/token/scope (admin or read)
  metrics_token: ""                     # optional Bearer token for /metrics only
  tls_cert: ""                          # path to TLS certificate PEM file
  tls_key: ""                           # path to TLS private key PEM file
  ui_dir: ""                            # path to UI files on disk (default: /opt/csm/ui)

# --- Email AV ---
email_av:
  enabled: false
  clamd_socket: "/var/run/clamd.scan/clamd.sock"  # path to ClamAV daemon socket
  scan_timeout: "30s"                   # per-attachment scan timeout
  max_attachment_size: 26214400         # max single attachment size in bytes (25MB)
  max_archive_depth: 1                  # max nested archive extraction depth
  max_archive_files: 50                 # max files extracted from a single archive
  max_extraction_size: 104857600        # max total extraction size in bytes (100MB)
  quarantine_infected: true             # quarantine emails with infected attachments
  scan_concurrency: 4                   # parallel scan workers

# --- Email Protection ---
email_protection:
  password_check_interval_min: 1440     # how often to audit email passwords (default: 1440)
  high_volume_senders: []               # accounts expected to send high volume (skip rate alerts)
  rate_warn_threshold: 50               # emails per window before warning (default: 50)
  rate_crit_threshold: 100              # emails per window before critical (default: 100)
  rate_window_min: 10                   # rate check window in minutes (default: 10)
  known_forwarders: []                  # expected plain mail forwarders

  # PHP-relay detector (cPanel only; gated by platform.IsCPanel at startup).
  # Off by default. When enabled, the daemon spawns the inotify spool
  # watcher, runs a startup spool walk, and starts the Path 2b retro scan
  # on /var/log/exim_mainlog. See docs/src/detection-realtime.md#php-relay
  # for what each path actually triggers on.
  php_relay:
    enabled: false                      # opt in to start the watcher
    rate_window_min: 5                  # Path 1 rolling window
    header_score_volume_min: 5          # Path 1: don't score until script has emitted N msgs
    absolute_volume_per_hour: 30        # Path 2 threshold per script
    account_volume_per_hour: 0          # Path 2b operator override; 0 = auto-derive from cpanel.config maxemailsperhour
    reputation_failures_per_24h: 3      # Path 3 threshold (Stage 2)
    fanout_distinct_scripts: 3          # Path 4 threshold
    fanout_window_min: 5                # Path 4 window
    baseline_sigma: 3.0                 # Path 5 (Stage 3)
    baseline_observation_days: 7        # Path 5 (Stage 3)
    policies_dir: "/opt/csm/policies/php_relay"  # mailer_classes.yaml + http_proxy_ranges.yaml; SIGHUP-reloadable
  cloud_relay:
    allow_users: []                     # full mailbox opt-outs for cloud-relay detection
    allow_domains: []                   # domain-wide opt-outs for cloud-relay detection

  # Email forward guard (cPanel only). Opt-in MTA-native enforcement for
  # external forward copies. Enforce mode can hold null-sender backscatter and
  # bad-sender-IP copies before they relay to an external provider, while the
  # local mailbox copy still delivers. Spam, malware, and auth-fail signals are
  # accounted in dry-run until Exim content scanning is wired. CSM is not in the
  # live mail path; an installed Exim rule can keep holding matching copies even
  # if the daemon is down. Held copies can be released or deleted from the Email page.
  forward_guard:
    enabled: false                      # master switch (default off)
    dry_run: true                       # account/log only, do not actually hold (default true)
    quarantine_retention_days: 14       # held-copy retention window
    skip_forwarders: []                 # reserved forwarder exemptions; not enforced yet
    hold_signals:                       # signal toggles, each default true
      bounce_backscatter: true          # null-sender bounce backscatter (enforceable)
      spam_flagged: true                # message flagged as spam (dry-run/accounting only)
      malware: true                     # message carries malware (dry-run/accounting only)
      bad_sender_ip: true               # originating IP has bad reputation (enforceable)
      auth_fail: true                   # sender failed SPF/DKIM/DMARC auth (dry-run/accounting only)

# --- Firewall ---
firewall:
  enabled: false

  # Open ports (IPv4). SSH (22) is intentionally absent; uncomment in
  # the YAML lists if sshd listens on 22. TCP 853 is DNS-over-TLS;
  # UDP 853 is DNS-over-QUIC.
  # 6277/24441 are DCC/Pyzor network checks used by SpamAssassin.
  tcp_in: [20,21,25,26,53,80,110,143,443,465,587,853,993,995,2077,2078,2079,2080,2082,2083,2091,2095,2096]
  tcp_out: [20,21,25,26,37,43,53,80,110,113,443,465,587,853,873,993,995,2082,2083,2086,2087,2089,2195,2325,2703]
  udp_in: [53,443,853]
  udp_out: [53,113,123,443,853,873,6277,24441]

  # IPv6
  ipv6: false
  tcp6_in: []                           # if empty, uses tcp_in
  tcp6_out: []                          # if empty, uses tcp_out
  udp6_in: []                           # if empty, uses udp_in
  udp6_out: []                          # if empty, uses udp_out

  # Restricted ports (infra IPs only)
  restricted_tcp: [2086,2087,2325]      # WHM ports

  # Passive FTP range
  passive_ftp_start: 49152
  passive_ftp_end: 65534

  # Infra IPs/CIDRs/hostnames for firewall rules
  infra_ips: []

  # Rate limiting
  conn_rate_limit: 200                  # new connections/min per IP (CGNAT-tolerant)
  syn_flood_protection: true
  conn_limit: 400                       # max concurrent connections per IP (0 = disabled)

  # Per-port flood protection: rate-limit new connections per source IP and IP family.
  # Defaults are sized for a busy mail host: 600/300s = 120 new conns/min/IP,
  # which tolerates a Thunderbird/iPhone client opening 5-15 parallel sessions
  # while still capping single-IP flood storms.
  port_flood:
    - port: 25
      proto: tcp
      hits: 600
      seconds: 300
    - port: 465
      proto: tcp
      hits: 600
      seconds: 300
    - port: 587
      proto: tcp
      hits: 600
      seconds: 300

  # UDP flood protection
  udp_flood: true
  udp_flood_rate: 100                   # packets per second
  udp_flood_burst: 500                  # burst allowance

  # Country blocking
  country_block: []                     # ISO country codes to block
  country_db_path: ""                   # path to MaxMind DB (uses geoip config if empty)

  # Silent drop (no logging)
  drop_nolog: [23,67,68,111,113,135,136,137,138,139,445,500,513,520]

  # IP limits
  deny_ip_limit: 3000                   # max permanent blocked IPs
  deny_temp_ip_limit: 500               # max temporary blocked IPs

  # Outbound SMTP restriction
  smtp_block: false                     # block outgoing mail except allowed users
  smtp_allow_users: []                  # usernames allowed to send
  smtp_ports: [25,465,587]

  # Dynamic DNS
  dyndns_hosts: []                      # hostnames to resolve and whitelist periodically

  # Logging
  log_dropped: true                     # log dropped packets
  log_rate: 5                           # log entries per minute

# --- GeoIP ---
geoip:
  account_id: ""                        # MaxMind account ID
  license_key: ""                       # MaxMind license key
  editions:                             # MaxMind database editions
    - GeoLite2-City
    - GeoLite2-ASN
  auto_update: true                     # auto-update GeoIP databases (default: true when credentials set)
  update_interval: "24h"                # update check interval

# --- ModSecurity ---
modsec_error_log: ""                    # path to Apache/LiteSpeed error log for ModSec parsing
modsec:
  rules_file: ""                        # path to modsec2.user.conf
  overrides_file: ""                    # path to csm-overrides.conf
  reload_command: ""                    # command to reload web server (e.g. "/usr/sbin/apachectl graceful")

# --- Performance ---
performance:
  enabled: true
  load_high_multiplier: 1.0             # load average / CPU cores multiplier for warning (default: 1.0)
  load_critical_multiplier: 2.0         # load average / CPU cores multiplier for critical (default: 2.0)
  php_process_warn_per_user: 20         # per-user PHP process count warning (default: 20)
  php_process_critical_total_multiplier: 5  # total PHP processes / CPU cores for critical (default: 5)
  error_log_warn_size_mb: 50            # error log size warning threshold (default: 50)
  mysql_join_buffer_max_mb: 64          # MySQL join_buffer_size warning threshold (default: 64)
  mysql_wait_timeout_max: 3600          # MySQL wait_timeout warning threshold (default: 3600)
  mysql_max_connections_per_user: 10    # per-user MySQL connections warning (default: 10)
  redis_bgsave_min_interval: 900        # minimum seconds between Redis BGSAVE (default: 900)
  redis_large_dataset_gb: 4             # Redis dataset size warning threshold in GB (default: 4)
  wp_memory_limit_max_mb: 512           # WordPress memory_limit warning threshold (default: 512)
  wp_transient_warn_mb: 1               # WordPress transient data warning in MB (default: 1)
  wp_transient_critical_mb: 10          # WordPress transient data critical in MB (default: 10)

# --- Cloudflare ---
cloudflare:
  enabled: false                        # auto-whitelist Cloudflare IP ranges
  refresh_hours: 6                      # how often to refresh Cloudflare IPs (default: 6)

# --- Threat Intel ---
c2_blocklist: []                        # known C2 server IPs to block permanently
backdoor_ports: [4444,5555,55553,55555,31337]  # ports indicating backdoor activity

# --- Update check ---
updates:
  check_enabled: true                   # notify only; CSM never downloads or applies updates
  interval: "24h"                       # release check interval
  github_api_url: ""                    # optional release API mirror or test endpoint
  package_name: "csm"                   # apt/dnf package name for package-manager fallback

# --- Incidents ---
incidents:
  auto_close:
    enabled: true                       # auto-close idle open/contained incidents
    dry_run: false                      # log decisions without writing status changes
    by_kind:
      mailbox_takeover: 24h
      credential_spray: 24h
      web_account_compromise: 168h
  spray_suppression:
    enabled: false                      # collapse one-source credential spray into one incident
    dry_run: true
    distinct_mailboxes: 10
    severity_escalate_at: 50
    per_check: [email_auth_failure_realtime, pam_auth_failure, ssh_bruteforce]
    max_tracked_ips: 10000
    block_at_severity: ""              # "" | high | critical
  auto_block:
    enabled: false                      # block source IPs from incident correlations
    block_at_severity: ""              # "" | high | critical
    kinds: []                           # empty means all non-spray kinds with remote_ip

# --- Disabled checks (skip whole categories per host) ---
# Listed finding names disable the scheduled check runner(s) that emit them,
# including sibling findings from the same runner. Realtime findings are not
# affected. Use for whole categories that don't apply to a host (e.g. WAF/web
# checks on DNS-only cPanel servers, where httpd is installed but no virtual
# hosts serve traffic).
# For email-only suppression, use `alerts.email.disabled_checks` instead.
disabled_checks: []                     # e.g. [waf_status, waf_rules, waf_detection_only]

# --- Retention (bbolt growth control) ---
retention:
  enabled: false                        # opt-in; when true, a daily sweep prunes old entries and compacts bbolt
  findings_days: 90                     # keep active findings this long (0 disables the findings sweep)
  history_days: 30                      # keep findings-history entries this long
  reputation_days: 180                  # keep IP reputation/attack entries this long
  sweep_interval: "24h"                 # how often the retention goroutine runs
  compact_min_size_mb: 128              # don't consider compaction below this file size
  compact_fill_ratio: 0.5               # compact when used_bytes / file_size drops below this

# --- Sentry (error reporting) ---
sentry:
  enabled: false                        # ship panics and selected errors to a Sentry server
  dsn: ""                               # Sentry project DSN
  environment: "production"             # e.g. "production", "staging"
  sample_rate: 1.0                      # 0.0 -> 1.0 (capture all errors)
  debug: false                          # SDK debug logs to stderr

TLS Certificates

The Web UI serves over HTTPS. Configure TLS certificates under webui:

webui:
  tls_cert: "/var/cpanel/ssl/cpanel/mycpanel.pem"   # certificate PEM file
  tls_key: "/var/cpanel/ssl/cpanel/mycpanel.pem"     # private key PEM file

On cPanel servers, you can reuse the cPanel self-signed certificate (both cert and key are in the same PEM file). For production, use a proper certificate from Let’s Encrypt or your CA.

If tls_cert and tls_key are empty, the Web UI will not start.

Validation

csm validate           # syntax check
csm validate --deep    # syntax + connectivity probes (SMTP, webhooks)
csm config show        # display config with secrets redacted

Editing csm.yaml by hand

CSM stores a sha256 of the main config in integrity.config_hash and a separate digest of loaded drop-ins in integrity.confd_hash. It refuses to start if the on-disk files disagree with those values. This is a tamper-detection feature. There are two supported edit workflows depending on which fields you touch.

Fast path: SIGHUP reload (safe fields only)

For fields tagged as hot-reload-safe (alerts, thresholds, detection, suppressions, auto_response, bpf_enforcement, reputation, email_protection, disabled_checks), the daemon can accept the change without a restart:

sudo cp /etc/csm/csm.yaml /etc/csm/csm.yaml.bak-$(date +%s)

# edit /etc/csm/csm.yaml with your favourite editor

sudo systemctl reload csm
sudo journalctl -u csm -n 20 --no-pager

systemctl reload sends SIGHUP (wired via ExecReload= in the unit file). The daemon re-reads the file, validates it, diffs it against the running config, and if every change is on a field tagged hotreload:"safe" it swaps the new values into the live config and re-signs the integrity hashes on disk. The next check tick sees the new thresholds; fanotify marks are not dropped.

The tagged-safe top-level fields are alerts, thresholds, detection, suppressions, auto_response, bpf_enforcement, reputation, email_protection, and disabled_checks. The Settings API derives its restart hints from the same manifest that drives config.Diff, so UI hints and SIGHUP behavior cannot drift silently. Changes to their sub-keys are picked up on the next tick by the periodic scanners, the auto-response helpers (block/kill/quarantine/challenge/permission-fix), alert dispatch, and the heartbeat.

Two sub-keys are exceptions. They live under a safe-tagged parent but seed a long-lived in-memory structure at daemon startup; the reload accepts the edit and re-signs the hash, but the running structure keeps the old value until the next restart:

  • reputation.whitelist – seeded into the threat database at startup. The threat database exposes its own runtime API for adding and removing whitelist entries (via the Threat Intelligence page in the Web UI or the /api/v1/threat/* endpoints); those paths survive restarts because the threat database persists the runtime list to disk. Reloading reputation.whitelist from csm.yaml does not automatically propagate to the running threat database.
  • email_protection.known_forwarders – captured by the forwarder watcher at startup and read by scheduled forwarder and mail-filter checks. No runtime API yet; send a restart if you edit this list.

If you change either of the above, send systemctl restart csm instead of a reload. The rest of the sub-keys in every safe-tagged section are read per-call (inside check functions, auto-response helpers, alert dispatchers) and hot-reload cleanly on the next tick.

Look for one of three log shapes in the journal:

  • SIGHUP: config reloaded; safe fields updated: [thresholds] – success. The new values are live.
  • config_reload_restart_required: SIGHUP reload: restart-required fields changed: [hostname ...]; live config unchanged – the edit touched a field that cannot be hot-swapped. A Warning config_reload_restart_required finding is also emitted. Fall back to the restart path below.
  • config_reload_error: SIGHUP reload: parse failed ... or ... validation error ... – the file on disk is not loadable or fails csm validate. A Critical config_reload_error finding is emitted. The live config is unchanged; fix the file and repeat.

Restart path: unsafe fields

Fields not tagged hotreload:"safe" (the majority, including hostname, state_path, webui.listen, firewall.*, email_av.* and anything that survives only one re-init per daemon lifetime) require a full restart. The integrity check must be re-signed first:

sudo cp /etc/csm/csm.yaml /etc/csm/csm.yaml.bak-$(date +%s)

# edit /etc/csm/csm.yaml with your favourite editor

sudo /opt/csm/csm rehash     # re-signs integrity hashes
sudo /opt/csm/csm validate   # syntax + value sanity
sudo systemctl restart csm
sudo systemctl status csm    # confirm active, no crash-loop

If the restart fails (most commonly because rehash was skipped), roll back with sudo cp <backup> /etc/csm/csm.yaml && sudo systemctl restart csm. The backup carries its own matching hash so no second rehash is needed.

Config-management tools

Config-management workflows (Ansible, Puppet, Chef) should:

  • For safe changes, notify systemctl reload csm instead of restart. The daemon re-signs the hash itself; no separate csm rehash step is required.
  • For any change that may touch a restart-required field, run csm rehash before the restart notify fires. Or always send reload first, read the journal, and promote to restart only when the reload logs restart-required.

conf.d drop-ins

Files matching /etc/csm/conf.d/*.yaml are loaded after the main config and deep-merged on top of it. Override with --config-dir <path> or CSM_CONFIG_DIR; the flag wins when both are set.

  • Order: lexicographic by filename. Scalar keys in 20-overrides.yaml override the same keys in 10-base.yaml. Use a numeric prefix.
  • Merge semantics: maps merge recursively; scalars replace the value from the main file; lists append in fragment order. All-scalar lists drop duplicate entries while keeping the first occurrence; structured lists such as webui.tokens keep every entry.
  • Trust: override directories must be absolute, must exist, and must be owned by root or the running process. The directory and every loaded fragment must not be group- or world-writable. Safe symlinked fragments are allowed, so packaged profiles can still be linked into /etc/csm/conf.d/.
  • Integrity ownership: drop-ins cannot set the integrity block. Integrity metadata is stored only in the main config.
  • Hash: integrity.config_hash covers the main file and integrity.confd_hash covers loaded drop-ins. After editing a drop-in by hand, run csm rehash before restarting, or use systemctl reload csm so the daemon can re-sign after validating the merged config. Web settings saves refuse to bless a drop-in change that has not already been re-signed.
  • Use cases: packaged integration profiles (e.g. /usr/lib/csm/profiles/phpanel-agent.yaml symlinked into conf.d/), per-host automation that should not touch the operator’s csm.yaml, secret material rendered from a vault.
ls /etc/csm/conf.d/
# 10-phpanel-agent.yaml   20-tenant-overrides.yaml

csm validate                # validates the merged config
csm config show             # prints the merged, redacted config
csm config schema --json    # JSON Schema for editor / CI validation

csm validate and csm config show always operate on the merged config so you can audit the effective state without grepping fragments.

detection.direct_smtp_egress

Phase 3 detector. backend accepts auto, bpf, legacy, or none; ports must contain TCP ports in the 1-65535 range. See Direct SMTP egress.

bpf_enforcement

Phase 4 enforcement. Requires a BPF-capable connection tracker at runtime; auto falls back to legacy detection on older servers. See BPF enforcement.