Application security · public change 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.
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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/
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
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