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 biggubby.com.

Audit started 2026-09-01 · stack Django templates / Django 4.2 + Celery / MariaDB 10.6

7Already in place
21Shipped 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://biggubby.com/security/ returned HTTP 200; linked from landing and login.

    web/souvenirs/templates/souvenirs/security.html · web/souvenirs/security_log.py · web/souvenirs/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.

    web/ar_project/settings.py · web/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.

    web/ar_project/settings.py

  4. c1-4

    Hot-path composite indexes

    shipped

    Needed. Index souvenir lists, compile status, and order lookups.

    Executed. Added CREATE INDEX migrations on Souvenir (customer, created_at) and SouvenirOrder (user, status).

    Verified. Migration souvenirs.0014_hot_path_indexes applied on the live MariaDB.

    web/souvenirs/migrations/0014_hot_path_indexes.py · web/souvenirs/models.py

  5. c1-5

    Magic-byte checks on uploads

    shipped

    Needed. Target images used PIL; video/audio/GLB trusted extensions.

    Executed. Shared magic-byte helper on target images, video, audio, and 3D models.

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

    web/souvenirs/upload_validation.py · web/souvenirs/forms.py

  6. c1-6

    Gzip compression at the biggubby vhost

    shipped

    Needed. Edge nginx served uncompressed HTML/CSS/JS for this host.

    Executed. Enabled gzip for HTML, CSS, JS, JSON, and SVG on the biggubby.com server block only.

    Verified. Live /security is gzip-encoded when Accept-Encoding includes gzip.

    nginx/conf.d/default.conf

  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 · web/ar_project/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).

    web/souvenirs/session_security.py · web/souvenirs/apps.py

  3. c2-3

    Django Redis cache + cached sessions

    shipped

    Needed. Redis already served Celery, not Django cache or sessions.

    Executed. RedisCache + cached_db sessions when CELERY_BROKER_URL / REDIS_URL is set.

    Verified. RedisCache + cached_db sessions are enabled against redis:6379/1.

    web/ar_project/settings.py

  4. c2-4

    JSON security logs + optional syslog

    shipped

    Needed. No structured security logger or sanitized 500 page.

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

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

    web/ar_project/settings.py · web/souvenirs/error_responses.py

  5. c2-5

    Security headers and auth rate limits (biggubby vhost only)

    shipped

    Needed. No HSTS/nosniff/Referrer-Policy or login rate limits on this vhost.

    Executed. Added HSTS, nosniff, Referrer-Policy, SAMEORIGIN, and login/signup rate limits to the biggubby.com server block only. Other vhosts unchanged.

    Verified. Live vhost sends HSTS, nosniff, Referrer-Policy, SAMEORIGIN, and Permissions-Policy. Other vhosts still return 200.

    nginx/conf.d/default.conf

  6. c2-6

    Restrict ALLOWED_HOSTS

    shipped

    Needed. ALLOWED_HOSTS accepted any Host header.

    Executed. Host allowlist from DJANGO_ALLOWED_HOSTS (biggubby.com and www).

    Verified. ALLOWED_HOSTS no longer includes *.

    web/ar_project/settings.py

  7. c2-7

    Snyk SAST on the Django package

    shipped

    Needed. No live SAST result recorded for biggubby.

    Executed. Snyk Code scan of web/souvenirs 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 · .gitignore

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

    Celery hung-task recycle

    verified

    Needed. AR compile workers had no time-limit, so a stuck MindAR job could sit forever.

    Executed. Celery --time-limit 7200 / --soft-time-limit 6900 / --max-tasks-per-child 20.

    Verified. Live celery command includes those flags after recreate.

    docker-compose.yml

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://biggubby.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. Django HTML locations proxy through edge-waf. /media/ and /static/ stay on the TLS edge.

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

    nginx/conf.d/default.conf · edge-waf/

  4. c4-4

    Public scan copies vs original videos

    verified

    Needed. QR scan URLs are /media/... Changing FileField paths would break tokens.

    Executed. Copies of shirts/targets/compressed/audio live under media/scan/ with the same relative paths. nginx tries scan first. Original videos without a scan copy require a session cookie.

    Verified. Scan assets still load at /media/shirts|targets|videos/compressed. Anonymous GET of unpublished originals returns 403.

    web/souvenirs/scan_publish.py · nginx/conf.d/default.conf

  5. c4-5

    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)