# Hardening Audit Report — Werkout API (Django/Python) ## Audit Sources - 5 mapper agents (100% file coverage) - 8 specialized domain auditors (parallel) - 1 cross-cutting deep audit (parallel) - Total source files: 75 --- ## CRITICAL — Will crash or lose data (18 findings) **1. werkout_api/settings.py:16** | DEBUG=True hardcoded, never disabled in production - What: `DEBUG = True` set at module level. Production branch (when `DATABASE_URL` set) never overrides to `False` — the code is commented out (lines 142-157). `CORS_ALLOW_ALL_ORIGINS` on line 226 depends on DEBUG, so it's always `True`. - Impact: Full stack traces, SQL queries, internal paths exposed to end users. CORS allows any origin with credentials. - Source: Security, Config, Cross-cutting **2. werkout_api/settings.py:160** | SECRET_KEY falls back to 'secret' - What: `SECRET_KEY = os.environ.get("SECRET_KEY", 'secret')`. Neither `docker-compose.yml` nor any env file sets SECRET_KEY. - Impact: Session cookies, CSRF tokens, password hashes use a publicly known key. Complete auth bypass. - Source: Security, Config **3. werkout_api/settings.py:226** | CORS allows all origins with credentials in production - What: `CORS_ALLOW_ALL_ORIGINS = True if DEBUG else False` is always `True` (DEBUG never False). Combined with `CORS_ALLOW_CREDENTIALS = True` (line 231). - Impact: Any website can make authenticated cross-origin requests and steal data. - Source: Security, Config **4. registered_user/serializers.py:31** | Password hash exposed in API responses - What: `write_only_fields = ('password',)` is NOT a valid DRF Meta option. Correct: `extra_kwargs = {'password': {'write_only': True}}`. Password field is readable. - Impact: Hashed password returned in registration responses. Enables offline brute-force. - Source: Security, API, Cross-cutting **5. registered_user/views.py:83-90** | update_registered_user uses request.POST — JSON requests wipe user data - What: `request.POST.get(...)` only works for form-encoded data. JSON requests return `None` for all fields. Lines 88-90 set `first_name=None`, `email=None`, etc. and save. - Impact: Any JSON profile update silently corrupts user data. Email set to None breaks login. - Source: Security, Logic, Cross-cutting **6. registered_user/views.py:108-114** | Password update broken for JSON clients, can lock out user - What: `request.POST.get("new_password")` returns `None` for JSON. `set_password(None)` makes password uncheckable, permanently locking user out. - Impact: Password endpoint non-functional for JSON clients. Potential permanent account lockout. - Source: Security, Cross-cutting **7. registered_user/serializers.py:46** | Registration creates RegisteredUser with non-existent phone_number field - What: `RegisteredUser.objects.create(phone_number=self.context.get("phone_number"), ...)` — model has no `phone_number` field (removed in migration 0002). - Impact: User registration crashes with TypeError if phone_number is passed in context. - Source: Cross-cutting, Logic **8. scripts/views.py:43-45** | Anonymous cache wipe endpoint — no authentication - What: `clear_redis` view has no auth decorators. Active in `scripts/urls.py`. Any anonymous request wipes entire Redis cache. - Impact: Denial of service — any internet user can flush all cached data at will. - Source: Security **9. video/views.py:50-59** | Path traversal vulnerability in hls_videos - What: `video_name` and `video_type` from GET params concatenated directly into file paths without sanitization. `../../etc/passwd` sequences can access arbitrary files. - Impact: Arbitrary file read on the server. Route commented out in urls.py but view exists. - Source: Security **10. video/views.py:74** | Celery task called with zero arguments but requires filename - What: `create_hls_tasks.delay()` called with no args. Task signature `create_hls_tasks(filename)` requires one. - Impact: Every call to `/videos/create_hls/` crashes the Celery worker with TypeError. - Source: Celery, Cross-cutting **11. supervisord.conf:13** | Production runs Django dev server (runserver) instead of WSGI - What: `python manage.py runserver 0.0.0.0:8000` in production. `uwsgi.ini` exists but is unused. - Impact: Single-threaded, no request timeouts, not designed for production. Memory leaks. - Source: Config **12. supervisord.conf** | No Celery worker process configured - What: Only `django` and `nextjs` programs defined. No `[program:celery]` entry. - Impact: All `.delay()` calls queue tasks in Redis that are never consumed. Entire async task system non-functional. - Source: Celery, Config **13. supervisord.conf:13** | Auto-migrate on every container start - What: `python manage.py migrate` in startup command runs migrations automatically without review. - Impact: Destructive migrations run silently. Race conditions if multiple containers start simultaneously. - Source: Config **14. docker-compose.yml:8-10,26** | Database credentials hardcoded as postgres/postgres - What: `POSTGRES_USER=postgres`, `POSTGRES_PASSWORD=postgres` in compose file and DATABASE_URL. No `.env` override. - Impact: Trivial unauthorized access if database port exposed. Credentials in git history permanently. - Source: Security, Config **15. AI/workouts.py + AI/cho/workouts.py** | 86K lines of PII data committed to git - What: Two files totaling 86,000+ lines of user workout data from Future Fitness API with user IDs, S3 URLs, timestamps. - Impact: PII permanently in git history. Potential GDPR/privacy liability. - Source: Security, Config **16. generator/views.py:1032-1160** | save_plan has no transaction wrapping - What: Creates GeneratedWeeklyPlan, then loops creating Workout, Superset, SupersetExercise, GeneratedWorkout, PlannedWorkout objects. No `transaction.atomic()`. - Impact: Mid-loop failure (e.g., date parsing) leaves orphaned plan records. Partially saved plans with missing days. - Source: Data Integrity, Cross-cutting **17. generator/views.py:789-803** | confirm_plan has no transaction wrapping - What: Loops through generated workouts, saves status, deletes/creates PlannedWorkouts individually. - Impact: Partial plan confirmation — some days accepted, others not, on any mid-loop error. - Source: Data Integrity **18. registered_user/serializers.py:34-51** | User + RegisteredUser creation has no transaction - What: `User.objects.create()`, `set_password()`, `RegisteredUser.objects.create()`, `Token.objects.create()` — four DB ops with no `transaction.atomic()`. - Impact: Orphaned User records if RegisteredUser creation fails. Ghost users block re-registration. - Source: Data Integrity, Cross-cutting --- ## BUG — Incorrect behavior (28 findings) **1. registered_user/views.py:30,47** | Validation errors return HTTP 500 instead of 400 - Impact: Clients can't distinguish server errors from bad input. **2. registered_user/views.py:74** | Failed login returns 404 instead of 401 - Impact: Wrong HTTP semantics for auth failures. **3. registered_user/models.py:20** | `__str__` concatenates nullable last_name — TypeError - Impact: Admin, logging crash for users with null last_name. **4. registered_user/admin.py:11** | Token.objects.get crashes if no token exists - Impact: Admin list page crashes if any user lacks a Token. **5. equipment/models.py:13** | `__str__` concatenates nullable category/name — TypeError - Impact: Admin crashes for Equipment with null fields. **6. muscle/models.py:11** | `__str__` returns None when name is null - Impact: Violates `__str__` contract. Admin/template crashes. **7. workout/views.py:45** | Workout.objects.get with no DoesNotExist handling - Impact: Missing workouts return 500 instead of 404. **8. workout/views.py:60,143,165** | Validation errors return HTTP 500 instead of 400 - Impact: Three views misreport client errors as server errors. **9. workout/views.py:69** | GET endpoint returns 201 Created instead of 200 - Impact: Incorrect HTTP semantics for read operation. **10. workout/views.py:76** | Unreachable None check — .get() raises exception, never returns None - Impact: Dead code; actual DoesNotExist is unhandled (500 error). **11. workout/views.py:124** | estimated_rep_duration None multiplication crashes - What: `exercise["reps"] * exercise_obj.estimated_rep_duration` where field can be null. `int * None` = TypeError. - Impact: Workout creation crashes mid-loop, orphaning partial records (no transaction). **12. workout/serializers.py:37** | KeyError if 'notes' not in validated_data - Impact: Completing a workout without notes crashes with 500. **13. workout/serializers.py:40** | Wrong attribute name — health_kit UUID never persisted - What: Sets `completed_workout.workout_uuid` but model field is `health_kit_workout_uuid`. - Impact: HealthKit UUIDs silently discarded forever. **14. workout/tasks.py:85** | estimated_rep_duration None multiplication in Celery task - Impact: Bulk import crashes mid-way, leaving partial data. **15. workout/tasks.py:73** | Exercise.objects.get with no DoesNotExist handling - Impact: One missing exercise aborts entire import. **16. workout/urls.py:14** | Duplicate URL name 'plan workout' on two paths - Impact: `reverse('plan workout')` resolves to wrong URL. **17. scripts/views.py:37** | NameError: MuscleGroup is not defined - What: Catches `MuscleGroup.DoesNotExist` but only `Muscle` is imported. - Impact: NameError crashes endpoint instead of catching intended exception. **18. scripts/views.py:15** | equipment_required.split() crashes on None - Impact: sync_equipment crashes for any exercise with null equipment_required. **19. video/models.py:24** | save() missing *args in signature - Impact: Callers passing positional args (force_insert) get TypeError. **20. video/models.py:24-27** | HLS transcoding triggered on EVERY save, not just file changes - Impact: Redundant expensive ffmpeg jobs on metadata-only edits. **21. video/serializers.py:13** | video_file can be None — AttributeError - Impact: Video listing crashes if any Video has no file. **22. video/tasks.py:10** | Existence check uses wrong filename pattern — never matches - Impact: Guard clause never short-circuits; re-encodes every time. **23. generator/views.py:70** | RegisteredUser.objects.get repeated ~17 times with no DoesNotExist handling - Impact: Any user without RegisteredUser gets unhandled 500 on every generator endpoint. **24. superset/helpers.py:16** | Exercise.objects.get("First Up") with no error handling - Impact: Workout detail crashes if "First Up" exercise is missing. **25. superset/serializers.py:20** | get_unique_id returns random UUID per serialization - Impact: Frontend can't use unique_id as stable key. Breaks diffing/caching. **26. workout/models.py:51** | settings not imported — NameError on duration_audio()/weight_audio() - What: Relies on `from exercise.models import *` transitive import of settings. - Impact: NameError if transitive chain breaks. **27. workout_generator.py:909** | None multiplication when duration is None - Impact: Plan generation crashes if preferences have no duration set. **28. workout_generator.py:802** | sum(c.difficulty) crashes if any difficulty is None - Impact: Plan generation crashes for users with incomplete completion records. --- ## SILENT FAILURE — Error swallowed or ignored (5 findings) **1. generator/views.py:193,491,874,989,1156** | Broad except Exception catches all errors, leaks str(e) - Impact: Bugs masked. Internal details leaked to clients. **2. superset/helpers.py:19-23** | In-memory mutations on Exercise ORM object never saved - Impact: Changes silently lost. Risk of corrupting shared Exercise if accidentally saved. **3. workout/helpers.py:41** | ser_data.mutable = True is a no-op - Impact: No effect. Indicates confusion about data type. **4. audit_exercise_data.py:168-170** | except Exception: pass silently swallows all errors - Impact: Database errors during field checks silently ignored. **5. workout/views.py:32** | Infinite cache with incomplete invalidation - Impact: Generated workouts never appear in all_workouts until manual cache clear. --- ## RACE CONDITION — Concurrency issue (1 finding) **1. registered_user/views.py:34** | Email uniqueness check is a race condition - What: `User.objects.filter(email=email)` check followed by `serializer.save()`. No DB unique constraint visible. - Impact: Concurrent registrations can create duplicate email accounts. --- ## LOGIC ERROR — Code doesn't match intent (12 findings) **1. rules_engine.py:650** | Push/pull ratio check skipped when either count is zero - What: Condition requires both counts > 0. A workout with 2 push, 0 pull passes silently. - Impact: Unbalanced push-heavy workouts pass validation. **2. rules_engine.py:858-860** | Workout type match is a no-op for non-strength types - What: Non-strength branch unconditionally counts every exercise as matching (100% always). - Impact: HIIT/cardio/core workouts can contain arbitrary exercises without violations. **3. workout_generator.py:1459** | Workout type affinity matching NEVER works - What: `SPLIT_TYPE_WORKOUT_AFFINITY` uses underscore names like `'traditional_strength_training'` but comparison uses `wt.name.strip().lower()` which yields space-separated names. - Impact: All workout type assignments fall through to round-robin fallback. Push splits get assigned random types. **4. workout_generator.py:2070** | Modality check counts exercise capability, not actual assignment - What: Checks `ex.is_duration` (capability flag) not whether the entry was actually given duration. - Impact: False modality calculations for dual-modality exercises. **5. workout_generator.py:1404** | Diversify type count wrong on replacement - What: Doesn't subtract from the removed type count when replacing, only adds to candidate count. - Impact: Valid replacements rejected. Invalid ones accepted in edge cases. **6. workout_generator.py:2898** | Final conformance treats all warnings as blocking - What: `_is_blocking_final_violation` returns True for `severity in {'error', 'warning'}`. - Impact: Workouts crash with ValueError for minor advisory issues (cooldown missing, duration bias slightly off). **7. workout_generator.py:1209** | Recursive retry destroys cross-day dedup state - What: Failed attempt's exercises already recorded in week state via `accumulate_week_state`. Retry with different exercises creates ghost entries. - Impact: Later days in the week have artificially smaller exercise pools. **8. entry_rules.py:19** | Volume floor can violate workout type rep ranges - What: With `min_volume=12` and `rounds=1`, forces 12 reps. Strength (3-6 rep range) gets 12 reps. - Impact: Strength workouts get inflated reps contradicting their character. **9. rules_engine.py:441** | Push/pull counting double-counts dual-pattern exercises - What: Exercise with `'upper push, upper pull'` counted in BOTH push AND pull totals. - Impact: Inaccurate push:pull ratio calculations. **10. exercise_selector.py:631** | No-equipment path restricts to bodyweight only (contradicts docs) - What: MEMORY.md says "no equipment set = all exercises available." Code excludes all exercises with equipment entries. - Impact: Users without equipment config get dramatically reduced pool. **11. muscle_normalizer.py:163** | Glutes in both lower_push and lower_pull categories - Impact: Glute-dominant workouts get incorrect split classification, cascading into wrong type assignments. **12. exercise_selector.py:1274** | Substring partner matching causes false positives - What: `if base_name.lower() in partner.name.lower()` — "Curl" matches "Barbell Curl Right", "Hammer Curl Right", etc. - Impact: Wrong exercises paired as L/R counterparts. --- ## PERFORMANCE — Unnecessary cost (18 findings) **1. exercise/serializers.py:30,35** | N+1 per exercise for muscles + equipment (~3400+ queries on cache miss) - Impact: `/exercise/all/` cold cache: 1133 exercises × 3 queries each. **2. workout/serializers.py:56-77** | Triple N+1 on WorkoutSerializer (~5000+ queries) - Impact: `all_workouts` cache miss: 633 workouts × (muscles + equipment + exercise_count). **3. superset/serializers.py:32** | N+1 per superset for exercises, cascading through ExerciseSerializer - Impact: Each workout detail triggers O(supersets × exercises × 3) queries. **4. workout/helpers.py:14-71** | Cascade of N+1 queries in exercise list builder - Impact: ~80+ queries per workout detail (supersets + exercises + serializer chain). **5. generator/serializers.py:338** | N+1 for supersets in GeneratedWorkoutDetailSerializer - Impact: Plan detail views trigger dozens of cascading queries per day. **6. generator/views.py:1106** | Exercise.objects.get in triple-nested loop in save_plan - Impact: 5-day plan with 5 supersets × 3 exercises = 75 individual SELECT queries. **7. muscle_normalizer.py:218** | ExerciseMuscle query per exercise in analyzer (~19,000 queries) - Impact: `analyze_workouts` command fires ~19,000 queries for 633 workouts. **8. workout_analyzer.py:1332-1337** | 120 exists() checks in _step7 - Impact: 8 types × 3 sections × 5 goals = 120 individual queries. **9. recalculate_workout_times.py:53-58** | Triple-nested N+1 with no prefetch (~18,000 queries) - Impact: Command takes orders of magnitude longer than necessary. **10. exercise_selector.py:593,629** | M2M querysets not cached (excluded_exercises + available_equipment) - Impact: 15-30 redundant identical queries per workout generation. **11. populate_exercise_fields.py:1006** | Individual save() per exercise (1133 UPDATE queries) - Impact: Command takes minutes instead of seconds. No bulk_update. **12. plan_builder.py:64,82** | Redundant save() after create() on Workout and Superset - Impact: 2 unnecessary DB writes per superset creation. **13. Various views** | Infinite cache with no invalidation strategy - What: equipment, exercise, muscle, workout, video views all use `cache.set(key, data, timeout=None)` with no invalidation. - Impact: New/edited data never appears until manual cache clear or restart. **14. workout/serializers.py:109** | Redundant re-fetch of registered_user - Impact: Extra query per workout detail for no reason. **15. generator/views.py:570-572,604-607** | N+1 save pattern for re-ordering after delete - Impact: Up to N individual UPDATEs instead of 1 bulk_update. **16. generator/views.py:423-429,964-976** | N+1 for sibling exercise exclusion - Impact: N queries instead of 1 IN query for sibling workout exercises. **17. generator/views.py:70** | RegisteredUser.objects.get repeated 17x with no caching - Impact: 1 unnecessary query per API request across all generator endpoints. **18. exercise_selector.py:1063** | Potentially large retry loop in _weighted_pick - What: `max_attempts = len(pool) * 3` with weighted pools of 500+ entries = 1500+ iterations. - Impact: CPU-bound stall risk in constrained pools. --- ## SECURITY — Vulnerability or exposure (6 additional findings) **1. werkout_api/settings.py:140** | ALLOWED_HOSTS=['*'] in production - Impact: HTTP Host header injection, cache poisoning, password reset URL manipulation. **2. werkout_api/settings.py:1-231** | Missing all HTTPS/security hardening settings - What: No SECURE_SSL_REDIRECT, SECURE_HSTS_SECONDS, SESSION_COOKIE_SECURE, CSRF_COOKIE_SECURE, etc. - Impact: Cookies sent over plaintext HTTP. No HSTS protection. **3. werkout_api/settings.py:31** | Django Debug Toolbar enabled unconditionally - Impact: Exposes SQL queries, settings, request data at `/__debug__/` in production. **4. workout/views.py:24-33** | all_workouts returns ALL users' workouts (IDOR) - What: `Workout.objects.all()` with no ownership filter. - Impact: Any authenticated user sees every user's workout data. **5. workout/views.py:39-49** | workout_details has no ownership check (IDOR) - What: Any authenticated user can view any workout by guessing IDs. - Impact: Insecure Direct Object Reference. **6. workout/views.py:170-172** | GET endpoint triggers data mutation — bulk import - What: GET triggers Celery task importing workouts for hardcoded user IDs. Any authenticated user can trigger. - Impact: Data corruption via idempotent-violating GET. --- ## DATA INTEGRITY — Database/model consistency issues (5 findings) **1. workout/views.py:94-138** | add_workout has no transaction wrapping - Impact: Partial Workout/Superset records on mid-loop failure. **2. plan_builder.py:59-149** | create_workout_from_spec has no transaction wrapping - Impact: Core builder used by all generation paths creates orphaned records on error. **3. workout_analyzer.py:249-252** | _clear_existing_patterns deletes without transaction - Impact: If analysis crashes mid-way, ML pattern tables are empty with no recovery. **4. workout/tasks.py:11-101** | Bulk import has no transaction or idempotency - Impact: Partial imports, duplicate records on re-run. **5. workout/views.py:150** | datetime.now() without timezone in USE_TZ=True project - Impact: Incorrect PlannedWorkout filtering near midnight due to timezone mismatch. --- ## MODERNIZATION — Legacy pattern to update (4 findings) **1. Dockerfile:13** | Python 3.9.13 base image (EOL October 2025) - Impact: No further security patches. **2. requirements.txt** | All dependencies pinned to mid-2023 versions - Impact: Django 4.2.2 has had multiple security releases since. **3. supervisord.conf:24** | Next.js runs `next dev` in production - Impact: No production optimizations, source maps exposed. **4. Various models** | max_length on IntegerField/FloatField (no-op parameters) - What: 10+ fields across superset, workout, exercise models use meaningless `max_length` on numeric fields. - Impact: Misleading — suggests validation that doesn't exist. --- ## DEAD CODE / UNREACHABLE (4 findings) **1. exercise/serializers.py:5** | Import shadowed by local class definition - What: Imports `ExerciseMuscleSerializer` then redefines it locally. **2. exercise/models.py:4** | `from random import randrange` — imported but never used **3. audit_exercise_data.py:88-89** | Dead `.exclude()` clause — logically impossible condition **4. workout/views.py:76** | Unreachable None check after `.get()` --- ## FRAGILE — Works now but will break easily (5 findings) **1. exercise_selector.py:613** | Hard exclude to soft penalty conversion mutates instance state permanently - What: `_warned_small_pool` guard uses `hasattr` which survives `reset()`. - Impact: Once triggered, ALL subsequent selections treat hard-excluded exercises with soft penalty only. **2. exercise_selector.py:645** | Equipment map cache survives reset() — stale data possible - Impact: Low risk per-request but dangerous in long-running processes. **3. workout_generator.py:1046** | Working superset detection relies on name prefix 'Working' - Impact: Any naming inconsistency silently breaks trimming, padding, modality validation, compound ordering, rebalancing. **4. workout/models.py:51** | settings import via wildcard chain from exercise.models - Impact: Transitive dependency breaks if `*` re-export chain changes. **5. exercise_selector.py:260** | Working set exclusion icontains 'stretch' catches valid exercises - Impact: Exercises like "Stiff Leg Deadlift Stretch Position" incorrectly excluded from working sets. --- ## Summary ### Summary by Category | Category | Count | |----------|-------| | Critical | 18 | | Bug | 28 | | Silent Failure | 5 | | Race Condition | 1 | | Logic Error | 12 | | Performance | 18 | | Security | 6 | | Data Integrity | 5 | | Modernization | 4 | | Dead Code | 4 | | Fragile | 5 | | **Total** | **106** | ### Summary by Source | Source | Findings | |--------|----------| | Security Auditor | 34 | | Data Integrity/ORM Auditor | 64 | | Logic Errors Auditor | 42 | | Performance Auditor | 41 | | Generator Logic Auditor | 22 | | API Correctness Auditor | 43 | | Celery/Async Auditor | 24 | | Config/Deployment Auditor | 30 | | Cross-cutting Deep Audit | 35 | | *(after dedup)* | **106 unique** | ### Top 10 Priorities 1. **[CRITICAL] settings.py — DEBUG=True + SECRET_KEY='secret' + CORS wide open in production** — Three compounding security misconfigurations that enable session forgery, CSRF bypass, and full API data theft from any website. 2. **[CRITICAL] registered_user/views.py:83-90 — request.POST wipes user data on JSON update** — Any JSON profile update sets email, name, image all to None. Active, reachable endpoint. 3. **[CRITICAL] registered_user/serializers.py:31 — Password hash exposed in API** — Invalid DRF Meta option means hashed password is readable in registration responses. 4. **[CRITICAL] scripts/views.py:43 — Anonymous cache wipe** — Unauthenticated endpoint wipes entire Redis cache. Active route, no auth required. 5. **[CRITICAL] supervisord.conf — No Celery worker + dev server in production** — All async tasks (HLS transcoding, imports) silently queue and never execute. Django dev server handles all production traffic. 6. **[CRITICAL] generator/views.py — No transaction.atomic() on save_plan/confirm_plan** — Multi-object creation loops with no transaction wrapping leave orphaned records on any failure. 7. **[BUG] workout/serializers.py:40 — HealthKit UUID silently discarded** — Sets wrong attribute name (`workout_uuid` vs `health_kit_workout_uuid`). Data permanently lost. 8. **[BUG] workout/views.py:124 + tasks.py:85 — None multiplication on estimated_rep_duration** — Nullable field multiplied without null check. Crashes workout creation and bulk import. 9. **[LOGIC] workout_generator.py:1459 — Workout type affinity matching NEVER works** — Space vs underscore comparison means all type assignments fall through to random round-robin. 10. **[PERFORMANCE] Serializer N+1 queries — 5000+ queries on cache miss** — WorkoutSerializer, ExerciseSerializer, and SupersetSerializer each trigger per-object queries with no prefetching. Mitigated by infinite caching but devastating on any cache clear.