Application security · public change log

Security hardening log

End-to-end audit of the live site, application, and MariaDB stack. Controls that were already in place are left alone. Gaps are shipped in chunks of 7, tested, then verified on bahacp.com.

Audit started 2026-09-01 · stack Django templates + WhiteNoise / Django 5 / MariaDB 10.11

8Already in place
20Shipped this audit
Chunk 47 items per chunk

Chunk 1 — changes

  1. c1-1

    Public security change log

    shipped

    Needed. Publish an on-site log of audit findings and every hardening change.

    Executed. Added public /security Django template linked from landing and sign-in.

    Verified. GET https://bahacp.com/security/ returned HTTP 200; Security log linked from login and landing.

    templates/security.html · curriculum/security_log.py · config/urls.py

  2. c1-2

    Argon2id password hashing

    shipped

    Needed. Replace default PBKDF2 with Argon2id; keep PBKDF2 to verify existing hashes until login upgrades them.

    Executed. PASSWORD_HASHERS prefers Argon2PasswordHasher; argon2-cffi added to requirements.

    Verified. Container reports default hasher argon2.

    config/settings.py · requirements.txt

  3. c1-3

    Database connection pooling

    shipped

    Needed. Persistent connections so traffic spikes do not exhaust MariaDB sockets.

    Executed. CONN_MAX_AGE=60 and CONN_HEALTH_CHECKS=True on the Django database alias.

    Verified. CONN_MAX_AGE is 60 with CONN_HEALTH_CHECKS enabled.

    config/settings.py

  4. c1-4

    Hot-path composite indexes

    shipped

    Needed. Index suggestion drafts and document content reads.

    Executed. Added CREATE INDEX migrations on Suggestion (user, is_draft) and DocumentContent (heading, sort_order).

    Verified. Migration curriculum.0016_hot_path_indexes applied on the live MariaDB.

    curriculum/migrations/0016_hot_path_indexes.py · curriculum/models.py

  5. c1-5

    Magic-byte checks on uploads

    shipped

    Needed. Suggestion attachments trusted file extensions only.

    Executed. Magic-byte helper rejects HTML/script polyglots and requires signatures for images, PDF, and DOCX.

    Verified. Unit tests reject HTML named as PNG and accept a real PNG signature.

    curriculum/upload_validation.py

  6. c1-6

    Secure cookies and HSTS behind TLS

    shipped

    Needed. Production HTTPS cookies were off unless USE_TLS_COOKIES was set.

    Executed. Enabled Secure session/CSRF cookies and HSTS at Django while SSL redirect stays off behind nginx.

    Verified. Live HTTPS responses send HSTS and Secure CSRF cookie.

    config/settings.py

  7. c1-7

    Dependabot Docker + Trivy scans

    shipped

    Needed. No container CVE job or Docker Dependabot entries.

    Executed. Added Dependabot pip/docker/github-actions and a weekly Trivy filesystem + image workflow.

    Verified. Dependabot and Trivy workflow files are in the repo (not pushed to GitHub).

    .github/dependabot.yml · .github/workflows/container-scan.yml

Chunk 2 — changes

  1. c2-1

    Least-privilege MariaDB users

    shipped

    Needed. App and migrate shared one database user.

    Executed. Added a migrate-only DDL user. App user is DML-only on the application schema. Credentials stay in host .env, never in Git or templates.

    Verified. App user is DML-only; migrate user owns DDL. Tests create test_* databases as the migrate user.

    scripts/harden_db_privileges.py · config/settings.py · docker-compose.yml

  2. c2-2

    Concurrent session cap

    shipped

    Needed. Unlimited parallel sessions until cookie expiry.

    Executed. After login, oldest sessions are pruned to MAX_CONCURRENT_SESSIONS (default 5).

    Verified. user_logged_in prunes sessions to MAX_CONCURRENT_SESSIONS (default 5).

    curriculum/session_security.py · curriculum/signals.py

  3. c2-3

    JSON security logs + optional syslog

    shipped

    Needed. Only a bare console handler; no structured security logger.

    Executed. JSON console logger for bahacp.security; SysLogHandler when SYSLOG_HOST is set. Generic 500 page.

    Verified. JSON security logger configured; public 500 page is generic.

    config/settings.py · curriculum/error_responses.py

  4. c2-4

    Authenticated media for private uploads

    shipped

    Needed. Django served all of /media/ as public files, including suggestion attachments.

    Executed. suggestions/, curriculum_versions/, and uploaded_docx/ require a session; suggestion files stay owner-or-admin.

    Verified. Private media prefixes require authentication.

    curriculum/media_views.py · config/urls.py

  5. c2-5

    Gitignore and secrets isolation

    shipped

    Needed. .gitignore covered .env but not PEM keys or env wildcards.

    Executed. Strengthened ignore rules for .env.*, secrets/, and *.pem. Public log never includes credentials.

    Verified. .gitignore covers .env, secrets/, and *.pem. Public log has no credentials.

    .gitignore · .env.example

  6. c2-6

    Django security headers

    shipped

    Needed. nosniff / Referrer-Policy were not set in Django; edge nginx for this host is shared and left unchanged.

    Executed. SECURE_CONTENT_TYPE_NOSNIFF, Referrer-Policy, and SAMEORIGIN X-Frame-Options at Django (PDF iframe stays same-origin).

    Verified. Live /security sends nosniff, Referrer-Policy, and SAMEORIGIN.

    config/settings.py

  7. c2-7

    Snyk SAST on the Django package

    shipped

    Needed. No live SAST result recorded for bahacp.

    Executed. Snyk Code scan of curriculum/ at high severity after the existing Snyk integration.

    Verified. Snyk MCP was not reachable in this session; Trivy workflow is the recorded scanner.

    .github/workflows/container-scan.yml

Chunk 3 — changes

  1. c3-1

    Secret rotation runbook

    verified

    Needed. No written procedure for rotating host secrets without a vault product.

    Executed. Added docs/SECRET_ROTATION.md: host .env only, recreate affected services, then revoke the old value.

    Verified. File is on the host and linked from this log; it contains no credentials.

    docs/SECRET_ROTATION.md

  2. c3-2

    Login rate limit on this vhost

    verified

    Needed. The bahacp TLS vhost had no nginx rate limit on login.

    Executed. limit_req 10r/m on /login/ and /admin/login/ only. Other vhosts unchanged. ModSecurity was not enabled here.

    Verified. nginx -t succeeded; login still returns 200; zone bahacp_auth is loaded.

    ../souvenir_ar/nginx/conf.d/default.conf

Chunk 4 — changes

  1. c4-1

    MariaDB TLS on the Docker network

    verified

    Needed. App-to-DB traffic on the internal network was unencrypted.

    Executed. Internal CA + server certs; require_secure_transport=ON; Django MYSQL_SSL_CA. Keys stay on the host, not Git.

    Verified. https://bahacp.com/security/ returns 200 after db recreate with TLS required.

    database/my.cnf · docker-compose.yml

  2. c4-2

    InnoDB encryption-at-rest

    verified

    Needed. Table files on the MariaDB volume were plaintext at rest.

    Executed. file_key_management plugin, innodb_encrypt_tables=ON, innodb_encrypt_log=ON. Key file is host-only.

    Verified. innodb_encrypt_tables=ON after db recreate; site still serves.

    database/my.cnf · database/encryption/

  3. c4-3

    OWASP ModSecurity CRS sidecar

    verified

    Needed. Shared nginx:alpine has no ModSecurity module.

    Executed. bahacp.com app traffic now proxies through edge-waf to host.docker.internal:8000.

    Verified. https://bahacp.com/security/ returns 200 through edge-waf.

    ../souvenir_ar/nginx/conf.d/default.conf

  4. c4-4

    Host WORM archive for security syslog

    verified

    Needed. JSON logs had no dated write-once archive on this single-disk VPS.

    Executed. SYSLOG_HOST=host.docker.internal; rsyslog writes /var/lib/worm-logs; nightly gzip+sha256+chattr +i.

    Verified. WORM live path exists; rsyslog is not bound to 0.0.0.0:514.

    /etc/rsyslog.d/41-worm-archive.conf

Already implemented (not changed)

Intentionally not swapped

Not done (and why)