Most Django teams assume their code is safe because they use the ORM. The ORM handles SQL injection, right?

Mostly. But Django applications are far more than ORM calls. They include raw SQL, subprocess invocations, file handling, auth decorator logic, Celery tasks, management commands, template tags, signals, middleware, and admin customizations.

Static analysis tools can find real issues in all of these — but each tool catches different patterns. This guide breaks down 7 real vulnerability categories in Django code, explains which tools detect each one, and gives you the code to test it yourself.

TL;DR: what gets caught, what gets missed

Vulnerability patternBanditSemgrepSkylos
SQL injection via raw() with f-stringsYesYesYes
ORM bypass via .extra(), RawSQLYes (B610, B611)Partial: p/django rules need request data; audit rules flag any useNo for .extra()/RawSQL; Yes (SKY-D211) for cursor.execute()
Subprocess injection in management commandsYes: B602, graded Low for a literal command and High for a computed oneYesYes: SKY-D209 on every shell=True, plus SKY-D212 when input is traced
Hardcoded secretsYesYesYes
SSRF in viewsNoYes (p/django ssrf-injection-requests)Yes
Insecure deserializationYesYesYes
Dead views / dead Celery tasksNoNoPartial: orphan view yes, registered task no
Broken auth decorator patternsNoNo built-in rule (custom rule possible)No (needs product context)

The Skylos column reflects behavior reproduced on Skylos 4.47.1 (2026-10-09) for the exact examples below. The Bandit and Semgrep cells reflect their documented rules and rulesets, checked on 2026-10-09; verify them against the versions you use.

The secrets and deserialization rows aren't demonstrated below; they reflect each tool's documented rules. Skylos needs --secrets for secrets.


Pattern 1: SQL injection in raw queries

Django's ORM is safe by default. But every Django codebase eventually has raw SQL — reporting queries, bulk operations, legacy migrations.

# VULNERABLE: user input interpolated into raw SQL
def search_users(request):
    query = request.GET.get("q")
    users = User.objects.raw(f"SELECT * FROM auth_user WHERE username LIKE '%{query}%'")
    return render(request, "results.html", {"users": users})
# SAFE: parameterized query
def search_users(request):
    query = request.GET.get("q")
    users = User.objects.raw(
        "SELECT * FROM auth_user WHERE username LIKE %s",
        [f"%{query}%"]
    )
    return render(request, "results.html", {"users": users})

Bandit can flag string-built SQL with B608, its hardcoded SQL expression rule. It looks for SQL-like strings built through formatting, concatenation, .format(), f-strings, and similar patterns; it does not model Django's ORM semantics or full request-to-query dataflow.

Semgrep catches the vulnerable version with python.django.security.injection.sql.sql-injection-using-raw. It understands Django's parameterized raw() syntax and does not flag the safe version.

Skylos catches the vulnerable version via taint analysis — tracing data from request.GET through the f-string into raw().

Try it yourself:

# Save the vulnerable example as test_raw.py, then:
bandit test_raw.py          # Expect B608 on the vulnerable version
semgrep --config p/django test_raw.py  # Expect sql-injection-using-raw
skylos test_raw.py --danger            # Expect SQL injection finding

Pattern 2: ORM bypass methods

Beyond raw(), Django has several ORM methods that bypass query parameterization. These are the ones that surprise teams:

# VULNERABLE: .extra() with user input — an old API Django says it aims to deprecate, still common in production code
queryset.extra(select={"full_name": f"first_name || ' ' || '{user_input}'"})

# VULNERABLE: RawSQL expression with interpolation
from django.db.models.expressions import RawSQL
queryset.annotate(val=RawSQL(f"SELECT col FROM other WHERE id = {param}", []))

# VULNERABLE: cursor.execute() with f-string — common in management commands
from django.db import connection
with connection.cursor() as cursor:
    cursor.execute(f"DELETE FROM logs WHERE date < '{cutoff}'")

Bandit has a rule for each: B610 (django_extra_used) flags .extra() and B611 (django_rawsql_used) flags RawSQL. It may also catch the cursor.execute() f-string through B608 when the SQL is built with obvious string formatting.

Semgrep's p/django rules for these APIs (such as python.django.security.injection.sql.sql-injection-using-extra-where) fire only when request data reaches the call, and for .extra() only through where=, so they don't match this example as written. The audit rules avoid-query-set-extra and avoid-raw-sql flag any use, but they aren't in p/django, so add them explicitly. It may miss cursor.execute() in management commands unless you add a custom rule.

Skylos reports SKY-D211 for the cursor.execute(...) example. It has no sink for .extra(...) or RawSQL(...), so for those two APIs rely on Bandit's B610/B611 or Semgrep's audit rules.

Why this matters: Django's docs call .extra() an old API it aims to deprecate (QuerySet.extra), but it is still common in production code. RawSQL is the recommended replacement for some .extra() use cases but is equally dangerous with string interpolation.


Pattern 3: Subprocess injection in management commands

Management commands are a blind spot because developers think of them as internal tools:

# VULNERABLE: management command with user-controlled subprocess
from django.core.management.base import BaseCommand
import subprocess

class Command(BaseCommand):
    def add_arguments(self, parser):
        parser.add_argument("--repo", type=str)

    def handle(self, *args, **options):
        repo = options["repo"]
        subprocess.run(f"git clone {repo} /tmp/analysis", shell=True)

The --repo argument comes from the command line, but management commands can also be called programmatically via call_command("mycommand", repo=untrusted_input) — which means the input may not always be trusted.

Bandit catches shell=True with B602 and grades it by what it can see: a hardcoded command such as subprocess.run("ls -la", shell=True) is reported at Low severity, and a computed command like this f-string at High. It doesn't trace where a computed value came from, so options["repo"] and a constant built elsewhere look the same.

Semgrep catches it with python.lang.security.audit.subprocess-shell-true. That rule skips literal commands, so it's quieter than Bandit on hardcoded build and deploy scripts.

Skylos reports every shell=True call as SKY-D209 (HIGH), hardcoded commands included. When it traces input such as options["repo"] into the command, it also reports SKY-D212 (command injection, CRITICAL).

Try it yourself:

# Save as myapp/management/commands/clone_repo.py
bandit myapp/ -r     # Expect B602 on shell=True
semgrep --config auto myapp/  # Expect subprocess-shell-true
skylos myapp/ --danger        # Expect SKY-D209 and SKY-D212

Pattern 4: Broken auth decorator patterns

This category is where framework awareness matters most — and where most tools are blind.

# RISKY: requires login but not staff/permission check
from django.contrib.auth.decorators import login_required
from django.utils.decorators import method_decorator
from django.views import View

class AdminReportView(View):
    @method_decorator(login_required)
    def get(self, request):
        # Any authenticated user can access revenue data
        return JsonResponse(get_revenue_data())

The view requires login but not @staff_member_required or @permission_required("reports.view_revenue"). Any authenticated user — including a customer with a free account — can access admin revenue data.

Another pattern:

# RISKY: class-based view with dispatch override
@method_decorator(login_required, name="dispatch")
class SensitiveView(View):
    def dispatch(self, request, *args, **kwargs):
        # This code runs — but does the decorator protect it?
        # It depends on MRO and how method_decorator wraps dispatch.
        log_access(request)
        return super().dispatch(request, *args, **kwargs)

Bandit does not analyze Django decorators or view dispatch. No detection.

Semgrep does not have a built-in rule for checking decorator sufficiency on class-based views. You could write a custom rule, but matching decorator ordering on CBVs is complex in a pattern-matching system.

Skylos does not prove that AdminReportView requires staff access in this exact example. That authorization rule comes from product context, so encode it in a negative access-control test, or in a rule. For function-based views, a free Skylos YAML rule of type function can require permission_required or staff_member_required wherever login_required appears: scaffold one with skylos rules init, copy the file into ~/.skylos/rules/ (where Skylos 4.47.1 loads rule packs from), and scan with --quality. For class-based views, use a Semgrep rule.

Why this matters: Broken access control is #1 on the OWASP Top 10 (A01:2025). It's also one of the hardest categories for static analysis because "is this view properly protected?" depends on business context, not just code patterns.


Pattern 5: Dead views and orphaned URL patterns

Every Django project accumulates views that are no longer routed:

# views.py — this function exists but no URL pattern points to it
def legacy_export(request):
    """Export user data to CSV — replaced by DRF endpoint in v2."""
    users = User.objects.all().values("email", "first_name", "last_name", "date_joined")
    # ... builds CSV with PII
    return HttpResponse(content, content_type="text/csv")

Dead views are not just tech debt. They're importable and functional — if someone later adds a URL pattern pointing to one (or if a dynamic URL resolver picks it up), the code executes. When dead views handle PII or bypass current auth patterns, they become real attack surface.

Bandit has no concept of URL routing. It cannot tell whether a view is reachable.

Semgrep is pattern-based. It cannot cross-reference function definitions in views.py against urlpatterns in urls.py.

Skylos performs cross-module analysis and can flag view functions that have no corresponding URL route as dead code candidates.

How to check manually:

# Find all view functions/classes
grep -rn "def .*request" myapp/views/ | head -20

# Find all URL patterns
grep -rn "path\|re_path\|url(" myapp/urls/ | head -20

# Compare the two lists — any view not referenced is a candidate

Static analysis automates this comparison across the entire project.


Pattern 6: Dead Celery tasks

Same problem as dead views, different surface:

# tasks.py
from celery import shared_task

@shared_task
def send_weekly_digest(user_id):
    """Sends weekly email digest — disabled Q3 2025, never removed."""
    user = User.objects.get(id=user_id)
    send_email(user.email, build_digest(user))

If this task isn't called from any .delay(), .apply_async(), or beat schedule (beat_schedule, or CELERY_BEAT_SCHEDULE in Django settings), it's dead. But it's still registered with Celery's task registry, which means it can be invoked via the Celery CLI, the Flower dashboard, or a crafted message to the broker.

Bandit does not analyze task registration or invocation patterns.

Semgrep does not have built-in rules for Celery task liveness.

Skylos treats @shared_task and @app.task functions as registered entry points and keeps this exact task alive. Use the manual definition/invocation cross-check below, Celery schedule inspection, and operational evidence to decide whether a registered task is removable.

How to check manually:

# Find all task definitions
grep -rn "@shared_task\|@app.task" myapp/

# Find all task invocations
grep -rn "\.delay(\|\.apply_async(" myapp/

# Cross-reference — any defined task not invoked is a candidate

Pattern 7: SSRF in DRF views

Server-Side Request Forgery is increasingly common in API backends that accept callback URLs or webhook configurations:

# VULNERABLE: user-controlled URL passed to requests.get()
from rest_framework.views import APIView
from rest_framework.response import Response
import requests

class WebhookTestView(APIView):
    def post(self, request):
        url = request.data.get("callback_url")
        response = requests.get(url, timeout=5)
        return Response({"status": response.status_code})

An attacker can pass http://169.254.169.254/latest/meta-data/ (AWS metadata endpoint) or internal service URLs to probe the network from inside your infrastructure.

Bandit does not have SSRF-specific rules.

Semgrep's p/django ruleset includes ssrf-injection-requests, which matches request data such as request.data.get(...) flowing into requests.get(), so it covers this pattern too.

Skylos traces data from request.data through to requests.get() via taint analysis.

Try it yourself:

# Save the example as test_ssrf.py
semgrep --config p/django test_ssrf.py
skylos test_ssrf.py --danger

The false positive problem in Django projects

Detection is only useful if the signal-to-noise ratio is manageable. Here's where Django projects specifically suffer:

Bandit's known Django false positives

Bandit flags these in virtually every Django project:

  • mark_safe() — triggers B308 (the mark_safe call blacklist, "potential XSS") even on static strings like mark_safe("<br>"). Bandit's separate B703 check skips literals. In Django, mark_safe() on trusted content is the intended API.
  • assert statements — triggers B101 ("assert used") on every test assertion. Django test suites use assert extensively.
  • SECRET_KEY in settings — triggers B105 ("hardcoded password"). Django's SECRET_KEY in settings.py is expected; the real question is whether settings.py is committed with a production secret.
  • yaml.load() — triggers unsafe-YAML checks when called without a safe loader. Older pinned scanner versions and custom wrappers can still create noise here.

If you use Bandit on a Django project, configure a .bandit.yml to skip the noisiest rules:

# .bandit.yml — reduce Django-specific noise
skips:
  - B101  # assert in tests
  - B308  # mark_safe blacklist; B703 still checks non-literal mark_safe calls

Semgrep's advantage: Django-specific rulesets

Semgrep's p/django ruleset was written specifically for Django. Its injection rules fire when request data reaches Django APIs such as raw(), .extra() and outbound HTTP calls, rather than on every use. That tends to mean fewer false positives than generic Python pattern rules. If you only use one tool, Semgrep with the Django ruleset is a strong default.

Framework-aware analysis

Tools that understand Django's internal wiring — decorator dispatch, URL routing, ORM methods, task registration — produce fewer false positives because they don't flag framework-intended patterns as suspicious. This is the core advantage of framework-aware static analysis.


What static analysis cannot catch

Be honest about the limits. No tool in this category detects:

  • Business logic authorization bugs: a view that correctly requires login_required but serves data the user shouldn't see based on their subscription tier
  • Race conditions: TOCTOU bugs in balance checks, inventory management, or coupon redemption
  • Deployment settings read at runtime: a literal DEBUG = True is caught by Skylos SKY-L011 (--quality) and by Django's manage.py check --deploy (W018), but a value loaded from the environment needs runtime or infrastructure-level checks
  • Complex template injection: Jinja2/Django template logic that builds HTML through multiple layers of context
  • Logic errors in permission checks: if user.is_staff or user.is_superuser when the intent was and

Static analysis finds bugs that follow structural patterns. Logic bugs and business rule violations require code review, integration testing, or runtime monitoring.


Setting up Django security scanning in CI

The highest-impact approach is layering tools, each catching what others miss:

# .github/workflows/security.yml
name: Django Security Scan
on: [pull_request]
permissions:
  contents: read

jobs:
  security:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Set up Python
        uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
        with:
          python-version: "3.12"

      - name: Install tools
        run: python -I -m pip install bandit semgrep "skylos==5.1.1"

      - name: Skylos (framework-aware scan)
        run: skylos myproject/ --danger --secrets --quality --gate

      - name: Semgrep (Django rules)
        run: semgrep --config p/django myproject/ --error

      - name: Bandit (broad sweep, tuned)
        run: bandit -r myproject/ -c .bandit.yml --exit-zero

Use Skylos or Semgrep as the blocking gate (Skylos --gate and Semgrep --error make gate failures block the job). Use Bandit as an informational sweep with --exit-zero so it doesn't block PRs on known false positive categories.


Where to start: the Django security scanning checklist

If you're adding static analysis to a Django project for the first time, scan these surfaces first:

  1. Files with raw(), .extra(), or cursor.execute() — search for direct SQL injection
  2. Management commands — check for subprocess calls and unsanitized arguments
  3. Views that call requests.get/post — check for SSRF with user-controlled URLs
  4. Views without permission decorators — check whether auth requirements match the data sensitivity
  5. Celery tasks with no .delay() callers — dead tasks are hidden attack surface
  6. Any subprocess call with shell=True — command injection risk
# Scan your Django project now
pip install skylos
skylos your_project/ -a

Key takeaways

  1. Django's ORM protects you from SQL injection — until it doesn't. .extra(), RawSQL, and cursor.execute() are in most Django codebases. Static analysis finds them.

  2. Dead views and dead tasks are real attack surface. Unreachable code is still importable and functional. Framework-aware tools detect dead code that generic tools miss.

  3. Auth decorator bugs are invisible to most scanners, including the three compared here. The difference between login_required and permission_required on a view that serves revenue data is a real vulnerability — and whether the decorator matches the data's sensitivity is product context, so test it.

  4. False positives kill adoption faster than missed findings. A tool that flags every mark_safe("<br>") as XSS will be disabled within a week. Framework awareness is what makes the difference between a tool teams use and a tool teams ignore.

  5. Layer your tools. Use a framework-aware scanner as your primary gate and a broad pattern-matcher as a secondary sweep.


Try scanning your Django project: Install Skylos — free, open source, runs locally.