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

CSM - Continuous Security Monitor

Security monitoring and response for Linux web servers. Single Go binary that detects compromise, phishing, mail abuse, and suspicious activity - then auto-responds and alerts within seconds.

Originally designed as a full Imunify360 replacement for cPanel/WHM on CloudLinux/AlmaLinux. Also runs on plain Ubuntu/Debian + Nginx/Apache and on plain AlmaLinux/Rocky/RHEL + Apache/Nginx: the daemon auto-detects the OS, control panel, and web server at startup and picks the correct log paths, config candidates, and check set.

Includes nftables firewall (replaces LFD/fail2ban), ModSecurity management, email security, threat intelligence, hardening audit, performance monitoring, and a web dashboard.

See installation.md for supported platforms and how the check set differs between cPanel and non-cPanel hosts.

What CSM Does

csm daemon
 +-- fanotify file monitor         < 1s detection on /home, /tmp, /dev/shm
 +-- inotify log watchers          ~2s detection on auth, access, exim, FTP logs
 +-- PAM brute-force listener      Real-time login failure tracking
 +-- PHP runtime shield            auto_prepend_file protection
 +-- critical scanner (10 min)     34 checks: processes, network, tokens, logins, firewall
 +-- deep scanner (60 min)         28 checks: WP integrity, RPM, DB injection, phishing
 +-- nftables firewall engine      Kernel netlink API, IP sets, rate limiting
 +-- threat intelligence           IP reputation, attack scoring, GeoIP
 +-- ModSecurity manager           Rule deployment, overrides, escalation
 +-- email security                AV scanning, quarantine, password/forwarder audit
 +-- challenge server              Proof-of-work pages for suspicious IPs
 +-- alert dispatcher              Email, Slack, Discord, webhooks
 +-- web UI                        HTTPS dashboard with 14 authenticated pages
 +-- hardening audit               On-demand server hardening checks + scoring
 +-- performance monitor           PHP, MySQL, Redis, WordPress metrics

Performance

Benchmarked on production (168 accounts, 275 WordPress sites, 28M files):

ComponentSpeedMemory
fanotify monitor< 1 second~5 MB
Log watchers~2 seconds~1 MB
Critical checks (34)< 1 sec~35 MB peak
Deep checks (28)~40 sec~100 MB peak
Daemon idle-45 MB resident
Binary-~8 MB static

Built From Real Incidents

CSM was built after real attacks where GSocket reverse shells, LEVIATHAN webshell toolkits, credential-stuffed cPanel accounts, and phishing kits were found across production servers.

Installation

Supported Platforms

PlatformWeb serverPackageNotes
cPanel/WHM on CloudLinux / AlmaLinux / RockyApache (EA4) or LiteSpeed.rpmPrimary target. All 62 checks run.
Plain AlmaLinux / Rocky / RHEL 8+ / CentOS Stream 8+Apache (httpd) or Nginx.rpmGeneric Linux + web server checks. cPanel-specific checks are skipped cleanly.
Plain Ubuntu 20.04+ / Debian 11+Apache (apache2) or Nginx.debSame as above, with debsums/dpkg --verify in place of rpm -V.

The daemon auto-detects the OS, control panel (cPanel/Plesk/DirectAdmin/none), and web server (Apache/Nginx/LiteSpeed) at startup. The detected platform is logged at startup as:

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

Check it with journalctl -u csm.service | grep platform: after starting the daemon.

The package repository at mirrors.pidginhost.com/csm/ is the preferred install method for Debian and Ubuntu. Future updates are picked up automatically via apt upgrade, and package metadata is GPG-signed so the trust chain is enforced by dpkg.

# 1. Install the signing key
curl -fsSL https://mirrors.pidginhost.com/csm/csm-signing.gpg | \
  sudo gpg --dearmor -o /etc/apt/keyrings/csm.gpg

# 2. Add the repository
echo "deb [signed-by=/etc/apt/keyrings/csm.gpg] https://mirrors.pidginhost.com/csm/deb stable main" | \
  sudo tee /etc/apt/sources.list.d/csm.list

# 3. Install
sudo apt update
sudo apt install csm

Works on Ubuntu 20.04+, Debian 11+, and any derivative. The single stable suite serves all Debian/Ubuntu releases – the Go binary is statically linked and has no per-release glibc dependency.

To upgrade later: sudo apt update && sudo apt upgrade csm.

# 1. Import the signing key into the RPM keyring
sudo rpm --import https://mirrors.pidginhost.com/csm/csm-signing.gpg

# 2. Add the repository
sudo tee /etc/yum.repos.d/csm.repo >/dev/null <<'EOF'
[csm]
name=CSM - Continuous Security Monitor
baseurl=https://mirrors.pidginhost.com/csm/rpm/el$releasever/$basearch
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://mirrors.pidginhost.com/csm/csm-signing.gpg
EOF

# 3. Install
sudo dnf install csm

The explicit rpm --import is important: without it, the first dnf install csm prompts “Is this ok [y/N]:” to trust the repo key, and dnf install -y answers package install prompts but not the key-trust prompt. If the prompt goes unanswered on a non-interactive install, dnf fails with repomd.xml GPG signature verification error: Signing key not found.

The $releasever variable auto-selects the matching EL major (8, 9, or 10). Both x86_64 and aarch64 are published. Works on AlmaLinux 8+, Rocky 8+, RHEL 8+, CloudLinux 8+, and cPanel-managed hosts.

To upgrade later: sudo dnf upgrade csm.

Quick Install (all platforms, one-shot)

For situations where you can’t add a package repository (disconnected hosts, air-gapped mirrors, Docker base images):

curl -sSL https://raw.githubusercontent.com/pidginhost/csm/main/scripts/install.sh | bash

Auto-detects hostname, email, and generates a WebUI auth token. Prompts for confirmation before applying. Works on Debian/Ubuntu and RHEL-family distros. Non-interactive mode:

curl -sSL https://raw.githubusercontent.com/pidginhost/csm/main/scripts/install.sh | bash -s -- --email admin@example.com --non-interactive

Manual .rpm / .deb download

If you need a specific version or want to install without adding the repository:

# RHEL family
curl -LO https://github.com/pidginhost/csm/releases/latest/download/csm-VERSION-1.x86_64.rpm
sudo dnf install -y ./csm-VERSION-1.x86_64.rpm

# Debian/Ubuntu
curl -LO https://github.com/pidginhost/csm/releases/latest/download/csm_VERSION_amd64.deb
sudo apt install -y ./csm_VERSION_amd64.deb

Replace VERSION with a real version (e.g. 2.2.2). Both files are also available at https://mirrors.pidginhost.com/csm/deb/pool/main/c/csm/ and https://mirrors.pidginhost.com/csm/rpm/elN/ARCH/ if you prefer to pin versions from the mirror.

Post-install (all methods)

vi /opt/csm/csm.yaml                   # Set hostname, alert email, infra IPs
csm validate                           # Check config syntax
csm baseline                           # Record current state as known-good
systemctl enable --now csm.service     # Start the daemon

Rollback to an older version

Both the APT and DNF repositories retain the last 5 tagged releases at any time. To downgrade:

# Debian/Ubuntu
sudo apt-cache policy csm              # Show available versions
sudo apt install csm=2.2.0-1

# RHEL family
sudo dnf --showduplicates list csm     # Show available versions
sudo dnf downgrade csm

Verifying platform auto-detection

After systemctl start csm.service, the first line after “CSM daemon starting” reports what CSM detected:

[2026-04-10 08:13:37] CSM daemon starting
[2026-04-10 08:13:37] platform: os=almalinux/10.0 panel=none webserver=apache
[2026-04-10 08:13:37] Watching: /var/log/secure
[2026-04-10 08:13:37] Watching: /var/log/httpd/error_log
[2026-04-10 08:13:37] Watching: /var/log/httpd/access_log

If any field shows none or unknown when you expect something, the auto-detect missed it. File a bug with the output of cat /etc/os-release, systemctl is-active nginx apache2 httpd, and which nginx apache2 httpd.

Optional system dependencies

CSM runs as a single static Go binary and has no hard dependencies beyond systemd, but a few host packages enable additional checks:

PackagePlatformsEnables
auditdAllShadow file / SSH key tamper detection via auditd
debsumsDebian/UbuntuCleaner system binary integrity output vs. dpkg --verify fallback
logrotateAllRotation of /var/log/csm/monitor.log
wp-cliOptionalWordPress core integrity check
ModSecurityAllWAF enforcement checks (see platform-specific install below)

Installing ModSecurity

CSM detects ModSecurity but doesn’t install it for you. Platform-specific commands:

# Ubuntu/Debian + Nginx
sudo apt install libnginx-mod-http-modsecurity modsecurity-crs

# Ubuntu/Debian + Apache
sudo apt install libapache2-mod-security2 modsecurity-crs && sudo a2enmod security2

# AlmaLinux/Rocky/RHEL + Apache (requires EPEL)
sudo dnf install -y epel-release
sudo dnf install -y mod_security
sudo systemctl restart httpd

# AlmaLinux/Rocky/RHEL + Nginx (requires EPEL)
sudo dnf install -y epel-release
sudo dnf install -y nginx-mod-http-modsecurity
sudo systemctl restart nginx

After installing ModSecurity, run csm check and the waf_status finding should disappear.

Manual (deploy.sh)

/opt/csm/deploy.sh install
vi /opt/csm/csm.yaml   # set hostname, alert email, infra IPs
csm validate
csm baseline
systemctl enable --now csm.service

Post-Install

  1. Edit /opt/csm/csm.yaml – set hostname, alert email, infrastructure IPs
  2. Run csm validate to check config syntax (add --deep for connectivity probes)
  3. Run csm baseline to record current state as known-good (see below)
  4. Start the daemon: systemctl enable --now csm.service
  5. Open the Web UI: https://<server>:9443/login

All installation methods produce the same installed state. RPM/DEB packages auto-detect hostname and email, and generate the auth token.

Baseline Scan

The csm baseline command scans the entire server and records the current state as known-good. This is required on first install so CSM knows what’s “normal” for your server.

What it does:

  • Scans all cPanel accounts for malware, permissions, and configuration issues
  • Records file hashes, email forwarder hashes, and plugin versions
  • Stores everything in the bbolt database (/opt/csm/state/csm.db)

How long it takes: Depends on server size. A server with 100+ cPanel accounts and thousands of WordPress sites can take 5-10 minutes. During this time, the daemon cannot start (bbolt lock).

When to re-run:

  • After a fresh install
  • After restoring from backup
  • If the database is lost or corrupted (delete csm.db and re-run)
  • You do NOT need to re-run for normal deploys/upgrades – the daemon handles incremental state

Important: The baseline scan holds the database lock. Do not start the daemon (systemctl start csm) until the baseline completes. The daemon will fail with “store: opening bbolt: timeout” if the baseline is still running.

Configuration

CSM is configured via a single YAML file at /opt/csm/csm.yaml.

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: "cluster6.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: "cluster6.example.com"

# --- Alerts ---
alerts:
  email:
    enabled: true
    to: ["admin@example.com"]
    from: "csm@cluster6.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
  heartbeat:
    enabled: false
    url: ""                             # healthchecks.io, cronitor, dead man's switch
  max_per_hour: 10                      # default: 10

# --- Integrity ---
integrity:
  binary_hash: ""                       # auto-populated by install/rehash
  config_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)
  plugin_check_interval_min: 1440       # WordPress plugin check interval (default: 1440)
  brute_force_window: 5000              # failed auth attempts window (default: 5000)

  # 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)

  # Mail brute-force tracker (Dovecot direct: IMAP/POP3/ManageSieve via /var/log/maillog)
  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)

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

# --- State ---
state_path: "/opt/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")
  enforce_permissions: false            # auto-chmod 644 world/group-writable PHP files
  block_cpanel_logins: false            # block IPs on cPanel/webmail login alerts
  netblock: false                       # auto-block /24 subnets
  netblock_threshold: 3                 # IPs from same /24 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

# --- Challenge Pages ---
challenge:
  enabled: false                        # enable PoW challenge pages instead of hard block
  listen_port: 8439                     # port for challenge server (default: 8439)
  secret: ""                            # HMAC secret for tokens (auto-generated if empty)
  difficulty: 2                         # SHA-256 proof-of-work difficulty 0-5 (default: 2)

# --- PHP Shield ---
php_shield:
  enabled: false                        # watch php_events.log for PHP Shield alerts

# --- Reputation ---
reputation:
  abuseipdb_key: ""                     # AbuseIPDB API key for IP reputation lookups
  whitelist: []                         # IPs to never flag as malicious

# --- 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)
  disabled_rules: []                    # YARA rule names to exclude from Forge downloads

`signatures.signing_key` is mandatory whenever either `signatures.update_url` is set or `signatures.yara_forge.enabled` is `true`.
The value must be the hex-encoded Ed25519 public key used to verify detached `.sig` files for downloaded rule bundles.
It is not a PEM block and not a filesystem path.

If you are not operating a signed remote rule feed yet, leave `update_url` empty and keep `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)
  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: []                  # accounts that forward mail (skip rate alerts)

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

  # Open ports (IPv4)
  tcp_in: [20,21,25,26,53,80,110,143,443,465,587,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,873,993,995,2082,2083,2086,2087,2089,2195,2325,2703]
  udp_in: [53,443]
  udp_out: [53,113,123,443,873]

  # 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 for firewall rules
  infra_ips: []

  # Rate limiting
  conn_rate_limit: 30                   # new connections/min per IP
  syn_flood_protection: true
  conn_limit: 50                        # max concurrent connections per IP (0 = disabled)

  # Per-port flood protection
  port_flood:
    - port: 25
      proto: tcp
      hits: 40
      seconds: 300
    - port: 465
      proto: tcp
      hits: 40
      seconds: 300
    - port: 587
      proto: tcp
      hits: 40
      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: 30000                  # max permanent blocked IPs
  deny_temp_ip_limit: 5000              # 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

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

Upgrading

/opt/csm/deploy.sh upgrade

This will:

  1. Stop the daemon
  2. Back up the current binary
  3. Download the new version
  4. Verify SHA256 checksum
  5. Extract UI assets and rules
  6. Rehash config
  7. Restart the daemon

Rolls back automatically on failure.

Troubleshooting

“store: opening bbolt: timeout” – Commands that need live daemon state now route through the control socket at /var/run/csm/control.sock and no longer open the database directly, so this error should only appear from csm baseline, csm firewall ..., or the check-* dry-run commands (the remaining in-process paths; see the roadmap for the phase-2 migration). If you hit it from one of those:

  • A csm baseline, csm scan, or csm check-deep command is still running
  • The daemon was killed uncleanly (SIGKILL, OOM) and the lock file is stale

Fix: check if a CSM process is running (pgrep csm). If not, remove the stale lock:

rm -f /opt/csm/state/csm.lock
systemctl start csm

“csm: daemon not running” – CLI commands that talk to the daemon (csm run-critical, csm run-deep, csm status) exit 2 with this message when the control socket is missing. Start the daemon with systemctl start csm. Bootstrap commands that run before the daemon exists (csm install, csm validate, csm verify, csm rehash) do not require it.

Never delete csm.db – it contains all historical findings, firewall state, email forwarder baselines, and per-account data. If you delete it, the web UI will show empty data until the next full scan cycle (up to 60 minutes for deep scan findings). If you must reset, use csm baseline instead.

Config changes require rehash – After editing csm.yaml, run csm rehash twice (the config hash is stored inside the config file, creating a circular dependency – the second run stabilizes it). Or just restart via systemctl restart csm.

RPM/DEB

yum update csm              # RPM
dpkg -i csm_NEW.deb         # DEB

Package managers handle stop/start automatically.

CLI Commands

Daemon

CommandDescription
csm daemonRun as persistent daemon (fanotify + inotify + PAM + periodic checks)

Checks

CommandDescription
csm runRun all checks once, send alerts
csm run-criticalCritical checks only (used by systemd timer)
csm run-deepDeep checks only (used by systemd timer)
csm checkRun all checks, print to stdout (no alerts)
csm check-criticalTest critical checks only
csm check-deepTest deep checks only
csm scan <user>Scan single cPanel account

Management

CommandDescription
csm installDeploy config, systemd, auditd rules, logrotate, WHM plugin
csm uninstallClean removal
csm baselineFull server scan, records current state as known-good. Takes 5-10 min on large servers. Required on first install.
csm rehashUpdate binary/config hashes without scanning. Use after config edits. Run twice (circular hash).
csm statusShow current state, last run, active findings
csm validateValidate config (--deep for connectivity probes)
csm config showDisplay config with secrets redacted
csm verifyVerify binary and config integrity
csm versionVersion and build info

Remediation

CommandDescription
csm clean <path>Clean infected PHP file (backs up original)
csm enable --php-shieldEnable PHP runtime protection
csm disable --php-shieldDisable PHP runtime protection

Updates

CommandDescription
csm update-rulesDownload latest signature rules
csm update-geoipUpdate MaxMind GeoLite2 databases

Firewall

23 subcommands. See Firewall for the full reference.

csm firewall status
csm firewall deny <ip> [reason]
csm firewall allow <ip> [reason]
csm firewall tempban <ip> <dur> [reason]
csm firewall deny-subnet <cidr> [reason]
csm firewall grep <pattern>
csm firewall flush
# ...

Real-Time Detection

CSM detects threats in under 2 seconds using three kernel-level watchers running inside the daemon.

fanotify File Monitor (< 1 second)

Monitors /home, /tmp, /dev/shm for filesystem events.

Detects:

  • Webshell creation (PHP files in web directories)
  • PHP in uploads, languages, upgrade directories
  • PHP in .ssh, .cpanel, mail directories (critical escalation)
  • Executable drops in .config
  • .htaccess injection (auto_prepend, eval, base64 handlers)
  • .user.ini tampering
  • Obfuscated PHP (encoded, packed, concatenated)
  • Fragmented base64 evasion ($a="base"; $b="64_decode" – function name split across variables)
  • Concatenation payloads (hundreds of $z .= "xxxx" lines with eval at end)
  • Tail scanning: payloads appended to the end of large legitimate PHP files (beyond the 32KB head window)
  • CGI backdoors: Perl, Python, Bash, Ruby scripts in web directories (e.g., LEVIATHAN toolkit)
  • SEO spam: gambling/togel dofollow link injection in PHP/HTML files
  • Phishing pages and credential harvest logs
  • Phishing kit ZIP archives
  • YAML signature matches (PHP, HTML, .htaccess, .user.ini)
  • YARA-X rule matches (if built with -tags yara)

Features:

  • Per-path alert deduplication (30s cooldown)
  • Process info enrichment (PID, command, UID)
  • Auto-quarantine on high-confidence matches (category + entropy validation)

inotify Log Watchers (~2 seconds)

Tails auth, access, and mail logs in real-time. The exact file paths are chosen per platform at daemon startup – see the platform: ... line in the daemon log.

LogPlatformsWhat it detects
cPanel session log (/usr/local/cpanel/logs/session_log)cPanel onlyLogins from non-infra IPs, password changes, File Manager uploads
cPanel access log (/usr/local/cpanel/logs/access_log)cPanel onlycPanel-API auth patterns
Auth logAllSSH logins and failures. /var/log/auth.log on Debian/Ubuntu, /var/log/secure on RHEL family and cPanel
Exim mainlog (/var/log/exim_mainlog)cPanel onlyMail anomalies, queue issues
Apache/LiteSpeed/Nginx access logAllWordPress brute force (wp-login.php, xmlrpc.php), real-time. Paths: /var/log/apache2/access.log (Debian), /var/log/httpd/access_log (RHEL), /var/log/nginx/access.log (Nginx), /usr/local/apache/logs/access_log (cPanel)
Dovecot log (/var/log/maillog)cPanel onlyIMAP/POP3 account compromise
FTP log (/var/log/messages)cPanel onlyFTP logins and failures
ModSecurity error logAll (if ModSec installed)WAF blocks and attacks. Auto-discovered from the detected web server
Nginx error log (/var/log/nginx/error.log)Nginx hostsGeneral web errors, ModSecurity denies

Cpanel-only log watchers are not registered on non-cPanel hosts, so you will not see “not found, retrying every 60s” warnings for them on plain Ubuntu or AlmaLinux.

SMTP / Dovecot Brute-Force Tracker

Detects credential stuffing and password spray against mail authentication. Runs as part of the Exim mainlog watcher on cPanel hosts.

Three attack patterns:

SignalWhat triggers itAuto-response
smtp_bruteforceA single attacker IP exceeds the per-IP failed-auth threshold within the configured windowIP blocked via nftables
smtp_subnet_sprayMultiple distinct attacker IPs from the same /24 subnet exceed the subnet thresholdEntire /24 subnet blocked via nftables
smtp_account_sprayMany distinct attacker IPs targeting the same mailbox exceed the account thresholdVisibility finding only. No auto-block, because attackers span many subnets and no single-IP action helps

Tunable via the thresholds.smtp_bruteforce_* keys in csm.yaml. Infrastructure IPs (from infra_ips) are never counted or blocked.

Mail Auth Brute-Force Tracker

Detects credential stuffing and password spray against IMAP, POP3, and ManageSieve. Runs as part of the Dovecot log watcher on cPanel hosts. The wrapper composes with the existing geo-based login monitor, so email_suspicious_geo keeps firing for successful logins from novel countries.

Four attack patterns:

SignalWhat triggers itAuto-response
mail_bruteforceA single attacker IP exceeds the per-IP failed-auth threshold within the configured windowIP blocked via nftables
mail_subnet_sprayMultiple distinct attacker IPs from the same /24 subnet exceed the subnet thresholdEntire /24 subnet blocked via nftables
mail_account_sprayMany distinct attacker IPs targeting the same mailbox exceed the account thresholdVisibility finding only. No auto-block, because attackers span many subnets and no single-IP action helps
mail_account_compromisedA successful login comes from an IP that just failed auth against the same accountIP blocked immediately. Rotate the password and revoke sessions

Tunable via the thresholds.mail_bruteforce_* keys in csm.yaml. Independent from the SMTP tracker so the Dovecot noise floor can be tuned separately. Infrastructure IPs are never counted or blocked.

Admin-Panel Brute-Force Tracker

Counts repeated POST requests to high-value non-WordPress admin login endpoints. Runs as part of the web access-log watcher.

Covered endpoints (tight set to avoid false positives on shared hosting):

  • phpMyAdmin: /phpmyadmin/index.php, /pma/index.php, /phpMyAdmin/index.php
  • Joomla: /administrator/index.php

When an IP crosses the POST-rate threshold, admin_panel_bruteforce fires and the attacker IP is auto-blocked.

Drupal /user/login and Tomcat Manager /manager/html are intentionally out of scope here. Drupal’s path is too generic on shared hosting, and Tomcat Manager uses HTTP Basic auth (repeated GET requests with 401 responses), not POST form submissions. Both need different detectors and are tracked as follow-up work.

PAM Brute-Force Listener

Real-time authentication monitoring across all PAM-enabled services.

  • SSH login tracking with geolocation
  • cPanel, FTP, and webmail authentication
  • Blocks IPs within seconds of threshold breach
  • Integrates with the nftables firewall for instant blocking

Critical Checks

34 checks, run every 10 minutes. Complete in under 1 second.

Process & System

CheckDescription
fake_kernel_threadsNon-root processes masquerading as kernel threads (rootkit indicator)
suspicious_processesReverse shells, interactive shells, GSocket, suspicious executables
php_processesPHP process execution, working dirs, environment variables
shadow_changes/etc/shadow modification outside maintenance windows
uid0_accountsUnauthorized root (UID 0) accounts
kernel_modulesKernel module loading (post-baseline)

SSH & Access

CheckDescription
ssh_keysUnauthorized entries in /root/.ssh/authorized_keys
sshd_configSSH hardening (PermitRootLogin, PasswordAuthentication, etc.)
ssh_loginsSSH access anomalies with geolocation
api_tokenscPanel/WHM API token usage
whm_accessWHM/root login patterns, multi-IP access
cpanel_loginscPanel login anomalies, multi-IP correlation
cpanel_filemanagerFile Manager usage for unauthorized access

Network

CheckDescription
outbound_connectionsRoot-level outbound to non-infra IPs (C2, backdoor ports)
user_outboundPer-user outbound connections (non-standard ports)
dns_connectionsDNS exfiltration and suspicious queries
firewallFirewall status and rule integrity

Brute Force & Auth

CheckDescription
wp_bruteforceWordPress login brute force (wp-login.php, xmlrpc.php)
ftp_loginsFTP access patterns and failed auth
webmail_loginsRoundcube/Horde access anomalies
api_auth_failuresAPI authentication failure patterns

Email

CheckDescription
mail_queueMail queue buildup (spam outbreak indicator)
mail_per_accountPer-account email volume spikes

Data & Integrity

CheckDescription
crontabsSuspicious cron jobs and scheduled commands
mysql_usersMySQL user accounts and privileges
database_dumpsDatabase exfiltration attempts
exfiltration_pasteConnections to pastebin/code-sharing sites

Threat Intelligence

CheckDescription
ip_reputationIPs against external threat databases (AbuseIPDB)
local_threat_scoreAggregated score from internal attack database
modsec_auditModSecurity audit log parsing

Performance

CheckDescription
perf_loadCPU load average thresholds
perf_php_processesPHP process count and memory
perf_memorySwap usage and OOM killer activity

Health

CheckDescription
healthDaemon health, binary integrity, required services

Platform Support

Runs on every supported platform unless noted below. The daemon auto-detects OS and panel at startup and silently skips cPanel-specific checks on plain Linux hosts (no “not found” spam).

cPanel-only (skipped on plain Ubuntu/AlmaLinux):

  • api_tokens, whm_access, cpanel_logins, cpanel_filemanager – read WHM API and cPanel session logs
  • wp_bruteforce – iterates /home/*/public_html/*/wp-login.php and per-domain access logs
  • webmail_logins – parses cPanel Roundcube/Horde logs
  • mail_queue, mail_per_account – read Exim queue and /var/log/exim_mainlog

Plain Linux equivalents that still provide coverage:

  • Access log brute-force detection (wp_login_bruteforce, xmlrpc_abuse) runs against the detected web server’s access log (/var/log/nginx/access.log or /var/log/httpd/access_log), so WordPress brute-force alerts still fire on non-cPanel hosts – they just rely on the live log watcher rather than per-domain domlog scanning.
  • modsec_audit runs on any host with ModSecurity installed.
  • ssh_logins, SSH brute force, PAM listener, firewall, kernel modules, RPM/DEB integrity, and threat intelligence all run on every supported platform.

Deep Checks

28 checks, run every 60 minutes. Thorough filesystem and database scans.

Filesystem

CheckDescription
filesystemBackdoors, hidden executables, suspicious SUID binaries
webshellsKnown webshell patterns (c99, r57, b374k, etc.)
htaccess.htaccess injection (auto_prepend_file, eval, base64 handlers)
file_indexIndexed file listing to detect new/unauthorized files
php_contentSuspicious PHP functions (exec, eval, system, passthru)
group_writable_phpWorld/group-writable PHP files (privilege escalation)
symlink_attacksSymlink-based privilege escalation attempts

WordPress

CheckDescription
wp_coreCore file integrity via official WordPress.org checksums
nulled_pluginsCracked/nulled plugin detection
outdated_pluginsPlugins with known CVEs
db_contentDatabase injection, siteurl hijacking, rogue admins, spam

Phishing & Malware

CheckDescription
phishing8-layer phishing detection (kit directories, credential harvesting)
email_contentOutbound email body scanning for credentials and suspicious URLs

System Integrity

CheckDescription
rpm_integritySystem binary verification via rpm -V
open_basediropen_basedir restriction validation
php_config_changesphp.ini modifications

DNS & SSL

CheckDescription
dns_zonesDNS zone file changes (MX record hijacking)
ssl_certsSSL certificate issuance (subdomain takeover)
waf_statusWAF mode, staleness, bypass detection

Email Security

CheckDescription
email_weak_passwordEmail accounts with weak passwords
email_forwarder_auditForwarders redirecting to external addresses

Performance

CheckDescription
perf_php_handlerPHP handler configuration (DSO vs CGI vs FPM)
perf_mysql_configMySQL my.cnf optimization
perf_redis_configRedis configuration
perf_error_logsError log file growth (bloat)
perf_wp_configWordPress wp-config.php settings
perf_wp_transientsWordPress database transient bloat
perf_wp_cronWordPress cron scheduling (missed crons)

Platform Support

The deep checks are the most cPanel-biased part of CSM because they iterate account home directories and per-user public_html trees. On plain Ubuntu/AlmaLinux the account-scan based checks do not run today:

cPanel-only (skipped on plain Linux):

  • htaccess, file_index, php_content, group_writable_php, symlink_attacks – iterate /home/*/public_html/**
  • wp_core, nulled_plugins, outdated_plugins, db_content – find WordPress installs under /home/*/public_html
  • phishing, email_content – scan user home directories and Exim spool
  • dns_zones, ssl_certs – read cPanel’s DNS zone store and SSL installation records
  • email_weak_password, email_forwarder_audit – read /etc/valiases, Dovecot/Courier auth databases
  • open_basedir, php_config_changes – read EA-PHP php.ini under /opt/cpanel/ea-php*/
  • perf_wp_config, perf_wp_transients, perf_wp_cron, perf_php_handler – WordPress and PHP handler introspection via cPanel’s EA-PHP layout

Runs on every platform:

  • filesystem, webshells – fanotify and file-tree scans over /home, /tmp, /dev/shm
  • rpm_integrity – dispatches to rpm -V on RHEL family or debsums / dpkg --verify on Debian family
  • waf_status – detects ModSecurity on Apache, Nginx, and LiteSpeed across all supported distros
  • perf_mysql_config, perf_redis_config, perf_error_logs – rely on standard service locations

A future release may add a config-driven account_roots option so the account-scan checks can iterate generic Linux webroots (/var/www/*, /srv/http/*, etc.). See the project roadmap.

Auto-Response

When enabled, CSM automatically responds to detected threats. All actions are logged in the audit trail.

Actions

ActionDescription
Kill processesFake kernel threads, reverse shells, GSocket. Never kills root or system processes.
Quarantine filesMoves webshells, backdoors, phishing to /opt/csm/quarantine/ with full metadata (owner, permissions, mtime). Restoreable from the web UI.
Block IPsAdds attacker IPs to the nftables firewall with configurable expiry. Rate-limited to 50 blocks/hour.
Clean malware7 strategies: @include removal, prepend/append stripping, inline eval removal, base64 chain decoding, chr/pack cleanup, hex injection removal, DB spam cleanup.
PHP shieldBlocks PHP execution from uploads/tmp directories, detects webshell parameters.
PAM blockingInstant IP block on brute force threshold breach.
Subnet blockingAuto-blocks /24 when 3+ IPs from the same range attack.
Permblock escalationPromotes temporary blocks to permanent after N repeated offenses.

Configuration

auto_response:
  enabled: true
  kill_processes: true
  quarantine_files: true
  block_ips: true
  block_expiry: "24h"         # default temp block duration
  netblock: true              # enable subnet blocking
  netblock_threshold: 3       # IPs from same /24 before subnet block
  permblock: true             # promote temp blocks to permanent
  permblock_count: 4          # temp blocks before promotion

Safety Guards

  • Never kills root processes, system daemons, or cPanel services
  • Infrastructure IPs (infra_ips in config) are never blocked
  • Quarantined files preserve full metadata for restoration
  • Auto-quarantine requires high confidence: category match (webshell/backdoor/dropper) + entropy >= 4.8 or hex density > 20%. This prevents legitimate WordPress plugins from being quarantined.
  • IP block rate limited to 50/hour to prevent runaway blocking
  • CRITICAL alerts always bypass the email rate limit (default 30/hour)
  • Trusted countries (trusted_countries) suppress login alerts from expected geolocations

What CSM Detects in Real-Time

Beyond standard malware patterns, CSM detects advanced evasion techniques:

  • Fragmented function names: attackers split base64_decode across variables ($a="base"; $b="64_decode") to evade simple string matching
  • Appended payloads: malicious code added to the end of large legitimate files, beyond typical scan windows. CSM scans both the first and last 32KB of every PHP file.
  • Non-PHP backdoors: Perl, Python, Bash CGI scripts in web directories (detects toolkits like LEVIATHAN)
  • SEO spam injection: gambling/togel dofollow link injection into theme files
  • WordPress brute force: real-time access log monitoring for wp-login.php and xmlrpc.php floods (blocks within seconds, not the 10-minute periodic scan)
  • Admin-panel brute force: same access-log path, tracks POSTs to /phpmyadmin/index.php, /pma/index.php, /phpMyAdmin/index.php, and Joomla /administrator/index.php. Emits admin_panel_bruteforce and auto-blocks the IP. Path matcher is intentionally tight to avoid false positives on shared hosting; Drupal and Tomcat Manager use different attack shapes and need separate detectors.
  • SMTP brute force: tails /var/log/exim_mainlog for dovecot SASL auth failures on submission ports. Emits smtp_bruteforce (per-IP, auto-blocks), smtp_subnet_spray (per-/24, auto-blocks the whole subnet), and smtp_account_spray (per-mailbox, visibility only).
  • Mail brute force: tails /var/log/maillog for direct IMAP, POP3, and ManageSieve auth failures. Composes with the existing geo-login monitor so email_suspicious_geo keeps working. Emits mail_bruteforce, mail_subnet_spray, mail_account_spray, and mail_account_compromised (the last one fires when a successful login arrives from an IP that just failed auth against the same mailbox; auto-blocks with no false positives by construction).

Firewall (nftables)

CSM includes a native nftables firewall engine that replaces LFD and fail2ban. It uses the kernel netlink API directly via google/nftables - no iptables, no Perl, no shell commands.

Features

  • Atomic ruleset - single netlink transaction, no partial application
  • Named IP sets with per-element timeouts (blocked, allowed, infra, country)
  • Rate limiting - SYN flood, UDP flood, per-IP connection rate, per-port flood
  • Country blocking via MaxMind GeoIP CIDR ranges
  • Outbound SMTP restriction by UID (prevent spam from compromised accounts)
  • Subnet/CIDR blocking with auto-escalation from individual IPs
  • Permanent block escalation after repeated temp blocks
  • Dynamic DNS hostname resolution (updated every 5 min)
  • IPv6 dual-stack with separate sets
  • Commit-confirmed safety - Juniper-style auto-rollback timer
  • Infra IP protection - refuses to block infrastructure IPs
  • cphulk integration - unblock flushes cphulk too
  • Audit trail - JSONL log with 10MB rotation
  • State persistence with atomic writes

CLI Commands

# Status
csm firewall status                              # Show status and statistics
csm firewall ports                               # Show configured port rules

# Block / Allow
csm firewall deny <ip> [reason]                  # Block IP permanently
csm firewall allow <ip> [reason]                 # Allow IP (all ports)
csm firewall allow-port <ip> <port> [reason]     # Allow IP on specific port
csm firewall remove <ip>                         # Remove from blocked and allowed
csm firewall remove-port <ip> <port>             # Remove port-specific allow

# Temporary
csm firewall tempban <ip> <dur> [reason]         # Temporary block
csm firewall tempallow <ip> <dur> [reason]       # Temporary allow

# Subnets
csm firewall deny-subnet <cidr> [reason]         # Block subnet
csm firewall remove-subnet <cidr>               # Remove subnet block

# Search
csm firewall grep <pattern>                      # Search blocked/allowed IPs
csm firewall lookup <ip>                         # GeoIP + block status lookup

# Bulk operations
csm firewall deny-file <path>                    # Bulk block from file
csm firewall allow-file <path>                   # Bulk allow from file
csm firewall flush                               # Clear all dynamic blocks

# Safety
csm firewall apply-confirmed <minutes>           # Apply with auto-rollback timer
csm firewall confirm                             # Confirm applied changes
csm firewall restart                             # Reapply full ruleset

# Profiles
csm firewall profile save|list|restore <name>    # Profile management

# Audit
csm firewall audit [limit]                       # View audit log

# GeoIP
csm firewall update-geoip                        # Download country IP blocks

Configuration

firewall:
  enabled: true
  ipv6: false
  conn_rate_limit: 30          # new connections per minute per IP
  syn_flood_protection: true
  conn_limit: 50               # max concurrent connections per IP
  smtp_block: false            # restrict outbound SMTP
  log_dropped: true

ModSecurity Integration

CSM detects and manages ModSecurity (WAF) on Apache, Nginx, and LiteSpeed across cPanel, plain Debian/Ubuntu, and plain AlmaLinux/Rocky/RHEL hosts. It deploys custom rules (cPanel only) and provides a web UI for rule overrides and escalation.

Supported Web Servers

Web serverConfig candidatesStatus checkCustom rule deployment
Apache on cPanel EA4/usr/local/apache/conf/*, /etc/apache2/conf.d/modsec*, whmapi1 modsec_is_installedYesYes (via cPanel modsec user conf)
Apache on Debian/Ubuntu/etc/apache2/mods-enabled/security2.conf, /etc/apache2/conf-enabled/*, /etc/apache2/conf.d/modsec2.confYesNot yet (plain Linux)
Apache on RHEL/Alma/Rocky/etc/httpd/conf.d/mod_security.conf, /etc/httpd/conf.modules.d/*YesNot yet (plain Linux)
Nginx on any distro/etc/nginx/nginx.conf, /etc/nginx/modules-enabled/50-mod-http-modsecurity.conf, /etc/nginx/modsec/main.confYesNot yet (plain Linux)
LiteSpeed/usr/local/lsws/conf/httpd_config.xml, /usr/local/lsws/conf/modsec2.confYesNot yet

When ModSecurity is not installed, the waf_status check emits a platform-specific install hint:

# On Ubuntu + Nginx:
Install: apt install libnginx-mod-http-modsecurity modsecurity-crs

# On Ubuntu + Apache:
Install: apt install libapache2-mod-security2 modsecurity-crs && a2enmod security2

# On AlmaLinux + Apache:
Install (requires EPEL): dnf install -y epel-release && dnf install -y mod_security

# On AlmaLinux + Nginx:
Install (requires EPEL): dnf install -y epel-release && dnf install -y nginx-mod-http-modsecurity

# On cPanel:
Install: WHM > Security Center > ModSecurity

Rule-staleness alerts scan both the flat CRS layout (/usr/share/modsecurity-crs/rules/*.conf) used by distro packages and the per-vendor subdirectory layout used by cPanel (/usr/local/apache/conf/modsec_vendor_configs/VENDOR/*.conf). Update instructions are also platform-specific (apt update && apt upgrade modsecurity-crs, dnf upgrade modsecurity-crs, or WHM on cPanel).

Features

  • Custom CSM rules - IDs 900000-900999 in configs/csm_modsec_custom.conf (cPanel only today)
  • Rule override management - SecRuleRemoveById directives for false positive suppression
  • Escalation control - change rule severity or action per-rule
  • WAF event log parsing - correlates events by IP, URI, and rule ID
  • Hot-reload - apply changes without Apache restart (cPanel only)

Web UI Pages

ModSecurity (/modsec) - WAF status overview, event log, active block list

ModSec Rules (/modsec/rules) - per-rule management:

  • View loaded rules with descriptions
  • Enable/disable individual rules
  • Override rule severity or action
  • Deploy custom rules

API Endpoints

GET  /api/v1/modsec/stats            WAF statistics
GET  /api/v1/modsec/blocks           Blocked request log
GET  /api/v1/modsec/events           WAF event details
GET  /api/v1/modsec/rules            Loaded rules list
POST /api/v1/modsec/rules/apply      Apply custom rules
POST /api/v1/modsec/rules/escalation Change rule severity/action

Signature Rules

CSM uses YAML and YARA-X rules for malware detection. Rules are stored in /opt/csm/rules/ and scanned both in real-time (fanotify) and during deep scans.

YAML Rules

rules:
  - name: webshell_c99
    severity: critical
    category: webshell
    file_types: [".php"]
    patterns: ["c99shell", "c99_buff_prepare"]
    min_match: 1

  - name: phishing_login
    severity: high
    category: phishing
    file_types: [".html", ".php"]
    patterns: ["password.*submit", "credit.*card.*number"]
    exclude: ["legitimate_form_handler"]
    min_match: 2

Fields:

  • name - unique rule identifier
  • severity - critical, high, or warning
  • category - webshell, backdoor, phishing, dropper, exploit
  • file_types - file extensions to match (or ["*"] for all)
  • patterns - literal strings or regex patterns
  • exclude - patterns that prevent a match (false positive reduction)
  • min_match - minimum patterns that must match

YARA-X Rules (Optional)

Build CSM with YARA-X support:

CGO_LDFLAGS="$(pkg-config --libs --static yara_x_capi)" go build -tags yara ./cmd/csm/

Place .yar or .yara files alongside YAML rules in /opt/csm/rules/. CSM compiles them at startup and uses them for:

  • Real-time fanotify file scanning
  • Deep scan filesystem sweeps
  • Email attachment scanning

Without the yara build tag, YARA rules are silently ignored.

Updating Rules

csm update-rules          # download latest rules and reload the running daemon

csm update-rules now asks the daemon to reload through the control socket once the download completes. If the daemon is not running, the next start picks the files up automatically. kill -HUP $(pidof csm) still works.

Or from the web UI: Rules page > Reload Rules button.

Remote rule updates are now signature-verified. Any configuration that enables signatures.update_url or signatures.yara_forge.enabled must also set signatures.signing_key to the 64-character hex-encoded Ed25519 public key that verifies the downloaded .sig files.

YARA Forge Integration

CSM can automatically fetch curated YARA rules from YARA Forge, which aggregates and quality-tests rules from 40+ public sources including signature-base, Elastic, Malpedia, and ESET.

Configuration

signatures:
  signing_key: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
  yara_forge:
    enabled: true
    tier: "core"              # core (5K rules, low FP), extended (10K), full (12K)
    update_interval: "168h"   # weekly
  disabled_rules:             # rule names to exclude from Forge downloads
    - SUSP_Example_Rule

signing_key must be a hex string for the Ed25519 public key that matches the private key used to sign the remote Forge artifact. It is not a PEM block and not a file path.

If you do not have a signed update source yet, disable remote updates instead:

signatures:
  signing_key: ""
  update_url: ""
  yara_forge:
    enabled: false

Tiers

TierRulesSizeFalse Positive Risk
core~5,0001.6 MBLow (quality >= 70, score >= 65)
extended~10,5003.3 MBMedium
full~11,6003.7 MBHigher (includes score >= 40)

Update Flow

  1. CSM checks the latest YARA Forge release tag on GitHub
  2. If newer than the installed version, downloads the ZIP for the configured tier and its detached signature
  3. Verifies the download against signatures.signing_key
  4. Filters out any rules listed in disabled_rules
  5. Compile-tests the rules with YARA-X before installing
  6. Atomically replaces the previous Forge rules file
  7. Reloads the YARA scanner

Custom rules in malware.yar are never overwritten by the Forge fetcher.

Disabling Rules

If a Forge rule produces false positives, add its name to disabled_rules in the config and reload:

signatures:
  disabled_rules:
    - SUSP_XOR_Encoded_URL
    - HKTL_Mimikatz_Strings

After editing, send SIGHUP or restart the daemon to apply.

How Rules Avoid False Positives

Signature rules require structural nesting, not co-presence of strings. Two dangerous function calls appearing in the same file but in unrelated code paths won’t trigger a rule. The call must directly wrap or chain with the other for a match.

Auto-quarantine adds a safety gate: files need Shannon entropy >= 4.8 or hex density > 20% before automatic quarantine. Legitimate plugin code (~4.2 entropy) passes through; obfuscated malware (~5.5+) is caught.

Alert Rate Limiting

Default: 30 emails/hour (configurable via max_per_hour). CRITICAL findings always get through regardless of rate limit. Only lower-severity alerts are rate-limited.

Suppressions

Create suppression rules to silence known false positives:

  • From the Findings page: click the suppress button on any finding
  • From the Rules page: manage suppression rules directly
  • Via API: POST /api/v1/suppressions

To suppress email alerts for specific checks while keeping them visible in the web UI, use disabled_checks in your config:

alerts:
  email:
    disabled_checks:
      - "email_spam_outbreak"
      - "perf_memory"

Email AV

CSM scans email attachments in real-time using ClamAV and YARA-X on the Exim mail spool.

How It Works

  1. fanotify watches the Exim spool directory for new messages
  2. Attachments are extracted and scanned by ClamAV (socket) and YARA-X (if available)
  3. Infected messages are quarantined with full metadata
  4. Sender, recipient, and message ID are logged

Web UI

The Email page (/email) shows:

  • AV watcher status (active, engine health)
  • Scan statistics (scanned, infected, quarantined)
  • Quarantined email list with release/delete actions

API Endpoints

GET  /api/v1/email/stats         Scan statistics
GET  /api/v1/email/quarantine    Quarantined email list
GET  /api/v1/email/av/status     AV watcher status
POST /api/v1/email/quarantine/   Release or delete quarantined email
  • email_content - scans outbound email body for credentials and suspicious URLs
  • email_weak_password - detects email accounts with weak passwords
  • email_forwarder_audit - audits forwarders for exfiltration redirects
  • mail_queue - alerts on queue buildup (spam outbreak indicator)
  • mail_per_account - per-account sending volume spikes

Threat Intelligence

CSM tracks, scores, and correlates attacks using a local attack database enriched with external feeds and GeoIP data.

Attack Database

  • Per-IP event tracking (brute force, webshell upload, phishing, C2, WAF block)
  • Threat score calculation with temporal decay (older attacks weighted less)
  • Auto-block on reputation threshold
  • Top attackers leaderboard

IP Intelligence

Combines multiple sources into a unified verdict:

SourceData
Local attack DBEvent count, types, score
AbuseIPDBExternal reputation (if API key configured)
Permanent blocklistOperator-managed persistent blocks
Firewall stateCurrently blocked/allowed status
GeoIPCountry, city, ASN, ISP
RDAPNetwork name, organization (cached 24h)

Verdicts: clean, suspicious, malicious, blocked

Web UI

The Threat Intel page (/threat) provides:

  • IP lookup with composite scoring
  • Top attackers with GeoIP enrichment
  • Attack type breakdown chart
  • Hourly trend chart
  • Whitelist management (permanent and temporary)

API Endpoints

GET  /api/v1/threat/stats            Attack stats and type breakdown
GET  /api/v1/threat/top-attackers    Top attacking IPs with GeoIP
GET  /api/v1/threat/ip               IP threat lookup
GET  /api/v1/threat/events           IP event history
GET  /api/v1/threat/whitelist        Whitelisted IPs
GET  /api/v1/threat/db-stats         Attack database statistics
POST /api/v1/threat/block-ip         Block IP permanently
POST /api/v1/threat/whitelist-ip     Permanent whitelist
POST /api/v1/threat/temp-whitelist-ip  Temporary whitelist
POST /api/v1/threat/clear-ip         Clear from attack DB
POST /api/v1/threat/unwhitelist-ip   Remove from whitelist

GeoIP

MaxMind GeoLite2 integration for IP geolocation and ASN enrichment.

Features

  • City database - country, city, latitude/longitude
  • ASN database - ISP, organization, autonomous system number
  • Auto-download on first use
  • Auto-update every 24 hours (configurable)
  • RDAP fallback for detailed ISP/org info (cached 24h)

Where It’s Used

  • Threat intel page (top attackers, IP lookup)
  • Firewall audit log (country flags)
  • Login alerts (geographic context)
  • Country-based login suppression (trusted_countries)
  • Country blocking (firewall CIDR ranges)

Configuration

geoip:
  account_id: "YOUR_MAXMIND_ACCOUNT_ID"
  license_key: "YOUR_MAXMIND_LICENSE_KEY"
  editions:
    - GeoLite2-City
    - GeoLite2-ASN
  auto_update: true
  update_interval: 24h

Free account: maxmind.com/en/geolite2/signup

CLI

csm update-geoip                    # Manual database update
csm firewall update-geoip           # Download country CIDR blocks
csm firewall lookup <ip>            # GeoIP + block status lookup

API

GET  /api/v1/geoip              IP geolocation (?ip=&detail=1)
POST /api/v1/geoip/batch        Batch lookup (array of IPs)

Challenge Pages

JavaScript proof-of-work challenge pages - a CAPTCHA alternative for suspicious IPs.

How It Works

  1. Suspicious IP hits a protected resource
  2. CSM serves a challenge page requiring client-side SHA-256 proof-of-work
  3. Browser computes the proof (shows progress bar)
  4. On valid solution, CSM issues an HMAC-verified token
  5. Subsequent requests pass through automatically

Features

  • SHA-256 based difficulty - configurable 0-5 levels
  • Client-side computation - no server load
  • HMAC token verification - prevents replay attacks
  • Nonce-based anti-replay
  • User-friendly - progress bar, instant feedback
  • Bot filtering - headless browsers and scripts fail the challenge

Use Cases

  • Gray-listing alternative to hard IP blocks
  • Protecting WordPress login pages
  • Rate limiting without blocking legitimate users
  • DDoS mitigation layer

Routing Behavior

When challenge.enabled: true, CSM routes eligible IPs to the challenge page instead of hard-blocking them. This works independently of auto_response settings.

Challenge-Eligible Checks

Login brute force (wp_login_bruteforce, cpanel_login_*), WAF triggers (modsec_*), XML-RPC abuse, FTP/SSH brute force, IP reputation, and other suspicious-but-not-confirmed-malicious activity.

Always Hard-Blocked

Confirmed malware (webshells, YARA/signature matches), C2 connections, backdoor ports, phishing pages, database injections, and spam outbreaks are always hard-blocked immediately, even when challenge is enabled.

Timeout Escalation

If an IP doesn’t solve the PoW challenge within 30 minutes, it is automatically escalated to a hard firewall block.

Trusted Proxies

By default, the challenge server uses RemoteAddr to identify clients. If deployed behind a reverse proxy (e.g. Apache with mod_rewrite), configure trusted_proxies so X-Forwarded-For is trusted only from those IPs:

challenge:
  enabled: true
  trusted_proxies:
    - "127.0.0.1"
    - "::1"

Without trusted_proxies, X-Forwarded-For is ignored to prevent IP spoofing.

Successful Verification

When a client passes the challenge:

  1. The IP is temporarily allowed through the firewall for 4 hours
  2. A verification cookie is set
  3. The IP is removed from the challenge list (Apache stops redirecting)

Performance Monitor

CSM monitors server performance metrics and generates findings when thresholds are exceeded.

Critical Checks (every 10 min)

CheckWhat it monitors
perf_loadCPU load average vs core count (critical/high/warning thresholds)
perf_php_processesPHP process count and total memory usage
perf_memorySwap usage percentage and OOM killer activity

Deep Checks (every 60 min)

CheckWhat it monitors
perf_php_handlerPHP handler type (DSO vs CGI vs FPM) and configuration
perf_mysql_configMySQL my.cnf settings (buffer pool, connections, query cache)
perf_redis_configRedis memory limits, persistence, eviction policy
perf_error_logsError log file sizes (bloat detection)
perf_wp_configWordPress wp-config.php hardening and debug settings
perf_wp_transientsWordPress database transient bloat
perf_wp_cronWordPress cron scheduling (missed crons, excessive events)

Web UI

The Performance page (/performance) shows real-time metrics:

  • Server load and CPU usage
  • PHP process and memory charts
  • MySQL and Redis health
  • WordPress performance indicators

API

GET /api/v1/performance    Current performance metrics snapshot

Web UI

HTTPS dashboard with polling-based live updates (10s feed, 30s stats). Dark/light theme toggle.

Pages

PageURLPurpose
Dashboard/dashboard24h stats, timeline chart, live feed, accounts at risk, auto-response summary, top attacked accounts
Findings/findingsActive findings with search, filter by check/account, grouping, fix/dismiss/suppress actions, bulk operations, on-demand account scan
Findings > History/findings?tab=historyPaginated archive of all findings with date range and severity filters, CSV export
Quarantine/quarantineQuarantined files with content preview, restore capability
Firewall/firewallBlocked IPs/subnets with GeoIP, whitelist management, search, audit log
ModSecurity/modsecWAF status, event log, active blocks
ModSec Rules/modsec/rulesPer-rule management, overrides, escalation control
Email/emailEmail AV status, quarantined attachments, scan statistics
Threat Intel/threatIP lookup with scoring/GeoIP/ASN, top attackers, attack type charts, trends
Hardening/hardeningOn-demand hardening audit, stored report, score, and remediation guidance
Incidents/incidentForensic timeline correlating events by IP or account
Rules/rulesYAML/YARA rule management, suppressions, state export/import, test alerts
Account/accountPer-account analysis: findings, quarantine, history, on-demand scan
Audit/auditSystem-wide action log (block, fix, dismiss, whitelist, restore)
Performance/performanceServer load, PHP processes, MySQL, Redis, WordPress metrics

Security

  • Authentication - Bearer token (header or HttpOnly/Secure/SameSite=Strict cookie)
  • CSRF - HMAC-derived token on all POST mutations
  • Headers - X-Frame-Options DENY, Content-Security-Policy, HSTS, nosniff
  • TLS - Auto-generated self-signed certificate
  • Rate limiting - 5 login attempts/min, 600 API requests/min per IP
  • Bearer auth skips CSRF (for API-to-API calls)

Keyboard Shortcuts

KeyAction
?Show shortcut help
/Focus search input
g dGo to Dashboard
g fGo to Findings
g hGo to Findings > History tab
g tGo to Threat Intel
g rGo to Rules
g bGo to Firewall
j / kMove selection down/up (Findings)
dDismiss selected finding
fFix selected finding

WHM Plugin

CSM installs a WHM plugin (addon_csm.cgi) that proxies the dashboard through WHM’s interface. All API URLs are rewritten via the CSM.apiUrl() helper to support this proxy mode.

API Reference

65+ REST endpoints. All require token authentication. POST mutations require CSRF token.

Authentication

# Bearer token (header)
curl -H "Authorization: Bearer YOUR_TOKEN" https://server:9443/api/v1/status

# Cookie-based (after login)
curl -b "csm_auth=YOUR_TOKEN" https://server:9443/api/v1/status

POST requests require the X-CSRF-Token header (obtained from the login response or page meta tag).

Status & Data

GET  /api/v1/status              Daemon status, uptime, scan state
GET  /api/v1/health              Daemon health (fanotify, watchers, engines)
GET  /api/v1/findings            Current active findings
GET  /api/v1/findings/enriched   Enriched findings with GeoIP, accounts, fix info
GET  /api/v1/finding-detail      Finding detail with action history (?check=&message=)
GET  /api/v1/history             Paginated history (?limit=&offset=&from=&to=&severity=&search=)
GET  /api/v1/history/csv         CSV export (up to 5,000 entries)
GET  /api/v1/stats               24h severity counts, accounts at risk, auto-response summary
GET  /api/v1/stats/trend         30-day daily severity counts
GET  /api/v1/stats/timeline      Event timeline
GET  /api/v1/quarantine          Quarantined files with metadata
GET  /api/v1/quarantine-preview  Preview quarantined file content (?id=)
GET  /api/v1/blocked-ips         Blocked IPs with reason and expiry
GET  /api/v1/accounts            cPanel account list
GET  /api/v1/account             Per-account findings, quarantine, history (?name=)
GET  /api/v1/audit               UI audit log
GET  /api/v1/export              Export state (suppressions, whitelist)
GET  /api/v1/incident            Incident timeline (?ip=&account=&hours=)
GET  /api/v1/performance         Performance metrics snapshot
GET  /api/v1/hardening           Last stored hardening audit report

GeoIP

GET  /api/v1/geoip               IP geolocation (?ip=&detail=1)
POST /api/v1/geoip/batch         Batch GeoIP lookup (JSON array of IPs)

Threat Intelligence

GET  /api/v1/threat/stats        Attack stats, type breakdown, hourly trend
GET  /api/v1/threat/top-attackers Top attacking IPs with GeoIP (?limit=)
GET  /api/v1/threat/ip           IP threat lookup (?ip=)
GET  /api/v1/threat/events       IP event history (?ip=&limit=)
GET  /api/v1/threat/whitelist    Whitelisted IPs
GET  /api/v1/threat/db-stats     Attack database statistics
POST /api/v1/threat/block-ip     Block IP permanently
POST /api/v1/threat/whitelist-ip       Permanent whitelist
POST /api/v1/threat/temp-whitelist-ip  Temporary whitelist (with expiry)
POST /api/v1/threat/clear-ip           Clear IP from attack database
POST /api/v1/threat/unwhitelist-ip     Remove from whitelist

Firewall

GET  /api/v1/firewall/status     Config, blocked/allowed counts
GET  /api/v1/firewall/subnets    Blocked subnets
GET  /api/v1/firewall/audit      Firewall audit log
GET  /api/v1/firewall/check      Check if IP is blocked (?ip=)
POST /api/v1/block-ip            Block an IP
POST /api/v1/unblock-ip          Unblock an IP
POST /api/v1/unblock-bulk        Bulk unblock IPs
POST /api/v1/firewall/deny-subnet      Block subnet
POST /api/v1/firewall/remove-subnet   Remove subnet block
POST /api/v1/firewall/flush            Clear all blocks
POST /api/v1/firewall/unban            Unblock IP + flush cphulk

ModSecurity

GET  /api/v1/modsec/stats              WAF statistics
GET  /api/v1/modsec/blocks             Blocked requests log
GET  /api/v1/modsec/events             WAF event details
GET  /api/v1/modsec/rules              Loaded rules list
POST /api/v1/modsec/rules/apply        Apply custom rules
POST /api/v1/modsec/rules/escalation   Change rule severity/action

Rules & Suppressions

GET  /api/v1/rules/status        YAML/YARA rule counts, version
GET  /api/v1/rules/list          Rule files
GET  /api/v1/suppressions        Suppression rules
POST /api/v1/rules/reload        Reload signature rules from disk
POST /api/v1/suppressions        Add or delete suppression rule
POST /api/v1/rules/modsec-escalation   ModSec escalation override

Email

GET  /api/v1/email/stats         Email scanning statistics
GET  /api/v1/email/quarantine    Quarantined email list
GET  /api/v1/email/av/status     Email AV watcher status
POST /api/v1/email/quarantine/   Release or delete quarantined email

Hardening

GET  /api/v1/hardening           Load last hardening audit report
POST /api/v1/hardening/run       Run hardening audit and save report

Actions

POST /api/v1/fix                 Apply fix for a finding
POST /api/v1/fix-bulk            Bulk fix multiple findings
POST /api/v1/dismiss             Dismiss a finding
POST /api/v1/scan-account        On-demand account scan
POST /api/v1/quarantine-restore  Restore quarantined file
POST /api/v1/test-alert          Send test alert through all channels
POST /api/v1/import              Import state bundle (suppressions, whitelist)

Building & Testing

Build

# Standard build (no YARA-X)
go build ./cmd/csm/

# Build with YARA-X support (requires libyara_x_capi)
CGO_LDFLAGS="$(pkg-config --libs --static yara_x_capi)" go build -tags yara ./cmd/csm/

Test

go test ./... -count=1           # all tests
go test -race -short ./...       # CI mode (race detector, skip slow tests)

Fuzz

CSM has a dozen parsers that read attacker-controlled input: Exim mainlog lines, Dovecot maillog lines, Apache Combined Log Format, /proc/net/tcp rows, wp-config.php bodies, /etc/shadow, auditd comm fields, and finding messages coming back from the WebUI.

Each parser has a Go fuzz target (files named fuzz_parsers_test.go under internal/checks/ and internal/daemon/). Fuzz targets do two things:

  1. Their seed corpus runs as part of the normal test suite. go test ./... executes every seed, so a known-bad input stays a regression test forever.
  2. The actual fuzzer runs with -fuzz=FuzzFoo.

Run a target for a fixed time while investigating:

go test ./internal/checks/... -run=^$ -fuzz=^FuzzExtractPHPDefine$ -fuzztime=30s

Run only the seeds:

go test -run=Fuzz ./internal/checks/... ./internal/daemon/...

If the fuzzer finds a crasher it writes the failing input to testdata/fuzz/FuzzFoo/<hash>. Commit that file alongside the fix and the input becomes a permanent seed.

Adding a fuzz target:

func FuzzMyParser(f *testing.F) {
    // Seeds: real-world valid shape, empty, malformed.
    f.Add("valid input")
    f.Add("")
    f.Add("corrupt/truncated")

    f.Fuzz(func(t *testing.T, s string) {
        _ = myParser(s)   // must not panic on any input
    })
}

Keep the target tight: call one function, assert it returns. Output verification belongs in a regular test.

Lint

make lint                        # must pass before push
gofmt -l .                       # must produce no output

make lint uses repo-local cache directories under .cache/ so the command behaves consistently in local shells, sandboxes, and CI runners.

Linter config in .golangci.yml: errcheck, govet, staticcheck, unused, ineffassign, gocritic, misspell, bodyclose, nilerr.

CI/CD

GitLab CI (.gitlab-ci.yml) is the internal build pipeline. It runs lint/test/package jobs, publishes internal packages, mirrors to GitHub, and creates the public GitHub release artifacts.

StageWhat it does
lintgolangci-lint, gofmt, gosec (blocking), govulncheck
testgo test -v -race -timeout=300s -covermode=atomic -coverprofile -coverpkg=./internal/... ./...
build-imageBuild CSM builder Docker image with YARA-X (manual trigger)
buildTwo architectures: amd64 with YARA-X CGO, arm64 pure Go
integrationSpin up AlmaLinux + Ubuntu cloud servers via phctl, install CSM from the public mirror, run the integration test binary on both hosts, collect coverage. Only runs on main
packageRPM + DEB via nFPM
signDetached signatures on release artifacts
publishInternal GitLab Generic Package Registry (versioned + latest)
repoPublish RPM/DEB to the public mirrors.pidginhost.com apt/dnf repos
pagesDocs + coverage HTML (GitLab Pages preview)
cleanupRemove old package versions
releaseGitLab release on tags matching v*
githubMirror to GitHub + upload release artifacts (auto on tag push)

Public Releases

To cut a release:

  1. Move the [Unreleased] heading in CHANGELOG.md to the new version (e.g. [2.4.2] - YYYY-MM-DD), commit as release: cut X.Y.Z.
  2. Tag and push:
    git tag vX.Y.Z
    git push origin main vX.Y.Z
    
  3. Wait. The tag pipeline runs integration, publishes packages to the mirror, creates the GitHub release, and uploads every artifact including the fresh merged-coverage.out. No manual pipeline clicks needed.

The coverage badge rebuilds automatically once the GitHub release exists, because the Pages workflow fetches merged-coverage.out from the latest release that carries one (it walks back through releases if the newest is missing the asset).

Installs and upgrades on end-user servers come from the GitHub release artifacts or the apt/dnf mirror. The internal GitLab package registry is operational tooling only.

Code Conventions

  • Imports: stdlib, blank line, third-party, blank line, internal. Use goimports -local github.com/pidginhost/csm
  • Errors: Return up the call stack. Wrap with fmt.Errorf("context: %w", err)
  • Store: store.Global() singleton bbolt DB. Always nil-check.
  • State: state.Store handles finding dedup, alert throttling, baseline tracking, latest findings persistence. Passed to subsystems at init
  • Web UI: Vanilla JS, no framework, no build step. Tabler CSS framework. All API calls via CSM.apiUrl() / CSM.post(). Escape with CSM.esc().
  • Logging: New code should use internal/log (wraps log/slog). Legacy fmt.Fprintf(os.Stderr, "[%s] ...", ts()) call sites remain valid until migrated.

Structured Logging (slog)

CSM’s daemon emits ~190 log lines via fmt.Fprintf(os.Stderr, "[%s] ...", ts()). The internal/log package provides a drop-in slog wrapper so operators can opt into JSON output for log-shipping pipelines (Loki, ELK, Datadog) without a big bang migration.

Operator controls

Two environment variables, read once at daemon startup:

VariableValuesDefaultEffect
CSM_LOG_FORMATtext, jsontextOutput handler
CSM_LOG_LEVELdebug, info, warn, errorinfoMinimum log level

Set via systemd drop-in:

# /etc/systemd/system/csm.service.d/logging.conf
[Service]
Environment="CSM_LOG_FORMAT=json"
Environment="CSM_LOG_LEVEL=info"

Then systemctl daemon-reload && systemctl restart csm.

Writing new logging code

import csmlog "github.com/pidginhost/csm/internal/log"

csmlog.Info("scan complete", "findings", len(f), "duration_ms", d.Milliseconds())
csmlog.Warn("log not found, will retry", "path", path, "retry_in", "60s")
csmlog.Error("alert dispatch failed", "err", err, "channel", "email")

Keys should be snake_case. Values should be machine-parseable (numbers, strings, booleans) – avoid formatted strings when you can pass the raw value.

Migrating legacy call sites

Migration is incremental and optional. The legacy format stays valid. Start with the hottest subsystems (alert dispatch, firewall operations, WAF handlers) where structured fields provide the most value, then work outward. Do not batch-convert – each subsystem should get a dedicated commit with before/after log samples in the PR description.

Keep the [TIMESTAMP] prefix of journalctl lines readable by humans: slog’s text handler uses time=... level=... msg=... which is also human-parseable, so journalctl viewers still work.

Building the Documentation

cd docs
mdbook build              # generates docs/book/
mdbook serve              # local preview at http://localhost:3000

Release Signing

CSM signs every release binary, tarball, and package with an ed25519 key. Installers verify signatures before touching disk, which lets operators trust the curl | bash install path and catches tampered mirror content.

Status

ReleaseSigned
v2.1.1 and olderNo (pre-signing era)
Next releaseYes (once CSM_SIGNING_KEY is set in CI)

Until the key is provisioned, releases ship unsigned and install scripts skip verification with a warning. This is the current state of the pipeline: all signing infrastructure is in place, but no key is configured.

One-Time Operator Setup

On a trusted workstation (NOT a CI runner):

# Generate the key pair
openssl genpkey -algorithm ed25519 -out csm-signing.key
openssl pkey -in csm-signing.key -pubout -out csm-signing.pub

# Store the private key
cat csm-signing.key
# Copy the entire output to GitLab > Settings > CI/CD > Variables:
#   Key:   CSM_SIGNING_KEY
#   Type:  Variable
#   Flags: Protected, Masked (if length allows; ed25519 PEMs are long
#          enough to need "masked and hidden")
#          Expose to protected branches/tags only

# Commit the public key into the repo
cat csm-signing.pub
# Paste the "-----BEGIN PUBLIC KEY----- ... -----END PUBLIC KEY-----"
# block into scripts/install.sh and scripts/deploy.sh at the
# EMBEDDED_SIGNING_KEY variable (currently empty).

Store the private key in an offline password manager and a second hardware location. Do not commit the private key to the repo. Do not email it to yourself. Do not paste it into Slack.

What Gets Signed

The sign:artifacts CI job runs after build and package. It signs:

  • csm-linux-amd64, csm-linux-arm64 (raw binaries)
  • csm-*-linux-*.rpm (RPM packages)
  • csm_*_amd64.deb, csm_*_arm64.deb (Debian packages)

The publish job signs csm-assets.tar.gz (because that tarball is created in the publish job, not the build stage). The release:github job re-signs csm-assets.tar.gz for the same reason – it rebuilds the tarball for the GitHub release.

Each signed artifact gets a .sig sibling uploaded to the same location. For example:

csm-2.2.0-linux-amd64
csm-2.2.0-linux-amd64.sha256
csm-2.2.0-linux-amd64.sig    <-- new

Signature Algorithm

Ed25519 “raw” signatures, meaning the signature covers the raw bytes of the artifact with no prior hashing wrapper. Verification uses:

openssl pkeyutl -verify -pubin -inkey csm-signing.pub -rawin \
  -sigfile csm-linux-amd64.sig -in csm-linux-amd64

This matches what scripts/install.sh and scripts/deploy.sh do internally.

Installer Behavior

Both install scripts read the public key from two sources in priority order:

  1. CSM_SIGNING_KEY_PEM environment variable (operator override at install time)
  2. EMBEDDED_SIGNING_KEY variable inside the script (set once when committing the public key)

If neither is set, the installer warns and proceeds – this lets pre-signing releases install. To enforce strict verification (fail rather than warn), export CSM_REQUIRE_SIGNATURES=1 before running the installer:

CSM_REQUIRE_SIGNATURES=1 curl -sSL https://raw.githubusercontent.com/pidginhost/csm/main/scripts/install.sh | bash

When a .sig file is published but verification fails, the installer always aborts (regardless of CSM_REQUIRE_SIGNATURES). A failed signature is always fatal – the installer never falls back to “trust on install”.

Key Rotation

To rotate the signing key:

  1. Generate a new key pair as described above.
  2. Update CSM_SIGNING_KEY in GitLab CI variables.
  3. Update EMBEDDED_SIGNING_KEY in scripts/install.sh and scripts/deploy.sh.
  4. Tag a new release. The sign:artifacts job will use the new key automatically.
  5. Existing deployed instances that use /opt/csm/deploy.sh upgrade will continue to work as long as the new release uses the new key (they read the embedded public key fresh each time from the updated deploy.sh).

Old releases remain verifiable with the old public key because the signatures are immutable. Archive the old public key alongside the new one so historical releases can still be validated.

Verifying a Release Manually

# Download artifact + sig + public key
curl -LO https://github.com/pidginhost/csm/releases/download/v2.2.0/csm-2.2.0-linux-amd64
curl -LO https://github.com/pidginhost/csm/releases/download/v2.2.0/csm-2.2.0-linux-amd64.sig
curl -LO https://raw.githubusercontent.com/pidginhost/csm/main/scripts/csm-signing.pub

# Verify
openssl pkeyutl -verify -pubin -inkey csm-signing.pub -rawin \
  -sigfile csm-2.2.0-linux-amd64.sig -in csm-2.2.0-linux-amd64
# Signature Verified Successfully

If that command fails, treat the binary as untrusted. Do not install it.