← Back

Complete Security Hardening: Bandit, Rate Limiting & PII Protection

·auth-security

Complete Security Hardening: Bandit, Rate Limiting & PII Protection

Automated security scanning revealed 47 vulnerabilities in our codebase: SQL injection risks, unhandled exceptions, PII exposure in logs, and no rate limiting. We implemented comprehensive security hardening across five categories, reducing critical issues to zero and establishing continuous security monitoring.

The Problem

Manual security reviews caught obvious issues, but subtle vulnerabilities accumulated over months of rapid development:

Discovered issues:

  • B608 (High): SQL injection via string formatting in 12 locations
  • B110 (Medium): Broad exception handling hiding errors in 23 locations
  • B112 (Low): Silent exception swallowing in 8 locations
  • Unlimited API requests (DoS vulnerability)
  • PII logged in plaintext (GDPR violation)
  • No automated security scanning in CI/CD

Security Posture: Before vs After

Before:

Security Posture
┌──────────────────────────────────────┐
│ - SQL injection risks (B608)         │
│ - No rate limiting                   │
│ - PII logged in plaintext            │
│ - No automated security scanning     │
│ - Manual security reviews only       │
└──────────────────────────────────────┘

After:

Hardened Security
┌──────────────────────────────────────┐
│ ✓ Parameterized queries (B608 fixed) │
│ ✓ Per-IP rate limiting               │
│ ✓ PII masking in logs                │
│ ✓ Bandit in CI/CD pipeline           │
│ ✓ 30-day data retention policy       │
│ ✓ Security audit documentation       │
└──────────────────────────────────────┘

Issue 1: SQL Injection (B608 - High Severity)

The Vulnerability

String formatting in SQL queries allows injection attacks:

# VULNERABLE CODE
email = request.args.get('email')
query = f"SELECT * FROM users WHERE email = '{email}'"
result = db.execute(query)

# Attack vector
# email = "'; DROP TABLE users; --"
# Resulting query: SELECT * FROM users WHERE email = ''; DROP TABLE users; --'

Bandit flagged 12 instances of this pattern with B608 warnings.

The Fix: Parameterized Queries

# SECURE CODE
email = request.args.get('email')
query = "SELECT * FROM users WHERE email = ?"
result = db.execute(query, [email])

# Attack attempt fails
# email = "'; DROP TABLE users; --"
# Treated as literal string, not SQL code

SQLAlchemy ORM provides built-in protection:

# SECURE (ORM)
email = request.args.get('email')
user = User.query.filter_by(email=email).first()

# SQLAlchemy automatically parameterizes
# Generated query: SELECT * FROM users WHERE email = ? BIND: ['user@example.com']

Edge Case: Dynamic Table Names

Parameterized queries don't support dynamic table names:

# CAN'T PARAMETERIZE TABLE NAMES
table_name = request.args.get('table')
query = f"SELECT * FROM {table_name}"  # Still flagged by Bandit

# Solution: Allowlist + validation
ALLOWED_TABLES = {'users', 'sessions', 'content'}

if table_name not in ALLOWED_TABLES:
    abort(400, "Invalid table name")

query = f"SELECT * FROM {table_name}"  # nosec B608 - validated allowlist

We added # nosec B608 comments with justifications for allowlisted dynamic queries.

Results: SQL Injection

  • Before: 12 SQL injection vulnerabilities
  • After: 0 vulnerabilities (11 parameterized, 1 allowlisted)
  • Time to fix: 3 hours

Issue 2: Rate Limiting (DoS Prevention)

The Vulnerability

APIs accepted unlimited requests, enabling:

  • Denial of Service (DoS) attacks
  • Credential stuffing attacks
  • API abuse and scraping
  • Cost inflation (Lambda invocations)
# VULNERABLE: No rate limiting
@app.route('/auth/login', methods=['POST'])
def login():
    # Accepts unlimited login attempts
    # Attacker can try 100,000 passwords
    email = request.json['email']
    password = request.json['password']
    return authenticate(email, password)

The Fix: Per-IP Rate Limiting

We implemented Redis-backed rate limiting:

from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

limiter = Limiter(
    app,
    key_func=get_remote_address,
    storage_uri="redis://localhost:6379",
    default_limits=["100 per minute"]
)

@app.route('/auth/login', methods=['POST'])
@limiter.limit("10 per minute")  # Stricter limit for auth
def login():
    email = request.json['email']
    password = request.json['password']
    return authenticate(email, password)

Rate Limit Configuration

Different endpoints have different limits:

RATE_LIMITS = {
    "default": "100 per minute",
    "auth_login": "10 per minute",
    "auth_register": "5 per minute",
    "content_fetch": "200 per minute",
    "admin": "1000 per minute"
}

Rate Limit Response

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 45

{
  "error": "Rate limit exceeded",
  "limit": 10,
  "window": "1 minute",
  "retry_after": 45
}

Monitoring Rate Limits

We added CloudWatch metrics:

def track_rate_limit_hit(endpoint, ip_address):
    """Track rate limit violations for monitoring."""
    cloudwatch.put_metric_data(
        Namespace='API/Security',
        MetricData=[{
            'MetricName': 'RateLimitHits',
            'Value': 1,
            'Dimensions': [
                {'Name': 'Endpoint', 'Value': endpoint},
                {'Name': 'IPAddress', 'Value': ip_address}
            ]
        }]
    )

Results: Rate Limiting

  • Before: Unlimited requests
  • After: 100 req/min per IP (customizable per endpoint)
  • Blocked attacks: 3 DoS attempts in first week
  • Reduced costs: $200/month savings from blocked malicious traffic

Issue 3: PII Exposure in Logs (GDPR Violation)

The Vulnerability

PII logged in plaintext violated GDPR Article 5 (data minimization):

# VULNERABLE CODE
logger.info(f"User registered: email={email}, phone={phone}")
logger.info(f"SMS sent to: {phone_number}")
logger.debug(f"Profile updated: {user_profile}")

# Log output
# User registered: email=john@example.com, phone=+1234567890
# SMS sent to: +1234567890
# Profile updated: {'email': 'john@example.com', 'ssn': '123-45-6789'}

The Fix: PII Masking

We implemented automatic PII masking:

import re

def mask_email(email):
    """Mask email addresses in logs."""
    if not email or '@' not in email:
        return email
    user, domain = email.split('@')
    return f"{user[0]}***@{domain[0]}***.{domain.split('.')[-1]}"

def mask_phone(phone):
    """Mask phone numbers in logs."""
    if not phone:
        return phone
    # Keep last 4 digits
    return f"+***{phone[-4:]}"

def mask_pii(text):
    """Mask all PII in text."""
    # Email pattern
    text = re.sub(
        r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b',
        lambda m: mask_email(m.group(0)),
        text
    )
    # Phone pattern
    text = re.sub(
        r'\+?\d{10,15}',
        lambda m: mask_phone(m.group(0)),
        text
    )
    return text

Logging Wrapper

We wrapped all logging calls:

class PIIFilter(logging.Filter):
    """Filter PII from all log records."""

    def filter(self, record):
        record.msg = mask_pii(str(record.msg))
        return True

# Apply to all loggers
logging.getLogger().addFilter(PIIFilter())

# Now logging is safe
logger.info(f"User registered: email={email}")
# Output: User registered: email=j***@e***.com

Data Retention Policy

We implemented 30-day automatic deletion:

-- Scheduled job (runs daily)
DELETE FROM user_logs
WHERE created_at < NOW() - INTERVAL 30 DAY;

DELETE FROM analytics_events
WHERE created_at < NOW() - INTERVAL 30 DAY
  AND event_type NOT IN ('purchase', 'subscription');  -- Keep financial records

Results: PII Protection

  • Before: Full PII in logs, indefinite retention
  • After: Masked PII, 30-day retention
  • GDPR compliance: ✓ Data minimization, ✓ Storage limitation
  • Risk reduction: PII exposure in log leaks eliminated

Issue 4: Exception Handling (B110, B112)

The Vulnerability

Broad exception handling hid errors:

# VULNERABLE CODE (B110)
try:
    result = risky_operation()
except:
    pass  # Silent failure, no logging

# VULNERABLE CODE (B112)
for item in items:
    try:
        process(item)
    except:
        continue  # Silent failure in loop

The Fix: Specific Exception Handling

# SECURE CODE
try:
    result = risky_operation()
except SpecificException as e:
    logger.error(f"Expected error in risky_operation: {e}")
    raise
except Exception as e:
    logger.exception(f"Unexpected error in risky_operation: {e}")
    raise

# Loop with specific exceptions
for item in items:
    try:
        process(item)
    except ValidationError as e:
        logger.warning(f"Invalid item {item.id}: {e}")
        continue  # nosec B112 - expected validation errors
    except Exception as e:
        logger.exception(f"Failed to process item {item.id}: {e}")
        # Don't continue, this is unexpected
        raise

Results: Exception Handling

  • Before: 31 broad exception handlers (23 B110, 8 B112)
  • After: 0 broad handlers (all specify exception types and log)
  • Bugs discovered: 8 silent failures now logged and fixed

Issue 5: Bandit CI/CD Integration

Implementation

We added Bandit to CircleCI pipeline:

# .circleci/config.yml
security-scan:
  docker:
    - image: python:3.9
  steps:
    - checkout
    - run:
        name: Install Bandit
        command: pip install bandit[toml]
    - run:
        name: Run security scan
        command: |
          bandit -r src/ -f json -o bandit-report.json
          bandit -r src/ -f txt  # Human-readable output
    - store_artifacts:
        path: bandit-report.json
    - run:
        name: Fail on high severity issues
        command: |
          bandit -r src/ -ll  # Only high severity

Bandit Configuration

# pyproject.toml
[tool.bandit]
exclude_dirs = ["tests", "migrations"]
skips = ["B101"]  # Skip assert_used (used in tests)

Results: CI/CD Integration

  • Before: Manual security reviews (quarterly)
  • After: Automated scans on every PR
  • Scan time: 15 seconds per build
  • Issues caught: 12 vulnerabilities prevented from reaching production

Comprehensive Results

Security Metrics

| Metric | Before | After | Improvement | |--------|--------|-------|-------------| | Critical issues (B608) | 12 | 0 | 100% | | Medium issues (B110) | 23 | 0 | 100% | | Low issues (B112) | 8 | 0 | 100% | | Rate limit protection | No | Yes | N/A | | PII exposure | Full | Masked | 100% | | Log retention | Indefinite | 30 days | GDPR compliant | | Automated scanning | No | Yes | N/A |

Cost Impact

  • Reduced Lambda costs: $200/month (blocked malicious traffic)
  • Reduced support tickets: 15/month (PII-related inquiries)
  • Compliance audit time: 40 hours saved (automated evidence)

Time Investment

  • Initial fixes: 16 hours
  • CI/CD setup: 4 hours
  • Documentation: 4 hours
  • Total: 24 hours

ROI: 24 hours investment saved 40+ hours in first audit alone.

Monitoring and Alerting

We added security monitoring:

# CloudWatch alarms
if rate_limit_hits > 100/hour:
    alert_security_team("Potential DoS attack")

if sql_injection_attempts > 5/hour:
    alert_security_team("SQL injection attempts detected")

if pii_access_anomaly:
    alert_security_team("Unusual PII access pattern")

Key Takeaways

  1. Automated scanning is essential - Bandit caught 47 issues humans missed
  2. Rate limiting prevents abuse - 3 DoS attacks blocked in first week
  3. PII masking is non-negotiable - GDPR compliance requires data minimization
  4. Parameterized queries prevent SQL injection - Never use string formatting for SQL
  5. CI/CD integration provides continuous security - Catch issues before production

Security Checklist for Teams

  • [ ] Run Bandit static analysis
  • [ ] Implement rate limiting (start with 100 req/min per IP)
  • [ ] Mask PII in all logs (emails, phones, SSNs)
  • [ ] Set data retention policies (30 days for PII)
  • [ ] Use parameterized queries (never string formatting)
  • [ ] Specific exception handling (avoid broad try/except)
  • [ ] Add security scanning to CI/CD
  • [ ] Monitor rate limit hits
  • [ ] Review security findings monthly

Resources


Implementation date: January-February 2026 Impact: 47 security issues resolved, GDPR compliance achieved, DoS protection enabled Time investment: 24 hours ROI: 40+ hours saved in first compliance audit