django-smart-layer 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_smart_layer-0.1.0.dist-info/METADATA +9 -0
- django_smart_layer-0.1.0.dist-info/RECORD +19 -0
- django_smart_layer-0.1.0.dist-info/WHEEL +5 -0
- django_smart_layer-0.1.0.dist-info/top_level.txt +1 -0
- smartlayer/__init__.py +1 -0
- smartlayer/admin.py +20 -0
- smartlayer/apps.py +39 -0
- smartlayer/management/__init__.py +0 -0
- smartlayer/management/commands/AILogAnalyser.py +184 -0
- smartlayer/management/commands/__init__.py +0 -0
- smartlayer/middleware/AIAnomalyDetector.py +337 -0
- smartlayer/middleware/AIRequestValidator.py +133 -0
- smartlayer/middleware/WatchLog.py +60 -0
- smartlayer/middleware/__init__.py +11 -0
- smartlayer/middleware/rate_Limiter.py +110 -0
- smartlayer/migrations/0001_initial.py +73 -0
- smartlayer/migrations/__init__.py +0 -0
- smartlayer/models.py +120 -0
- smartlayer/utils.py +60 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-smart-layer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AI-powered Django middleware for security, monitoring and rate limiting
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: django>=4.2
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Provides-Extra: scheduler
|
|
9
|
+
Requires-Dist: apscheduler>=3.10; extra == "scheduler"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
smartlayer/__init__.py,sha256=UrUAjawKmNA5QYHnzOzwQi_vx8h1DiFN3R2eLE5vQok,55
|
|
2
|
+
smartlayer/admin.py,sha256=IIesdhH1d3q9BnYPpNdv_OlIFbD4auTHta0E1wwphdw,868
|
|
3
|
+
smartlayer/apps.py,sha256=aGcP-0g8LWap8_k9i87GxaIHDSiLO-F7kJrbSuWguTk,1351
|
|
4
|
+
smartlayer/models.py,sha256=5MLOPsHrYYxojBH2FEFdiC3T0VEXWnrwDaUl69gcGdo,4718
|
|
5
|
+
smartlayer/utils.py,sha256=NVcrnP6Fg1AYtQNRi39xUBGVv1rOJ2-MkwDkJh70nbY,2274
|
|
6
|
+
smartlayer/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
smartlayer/management/commands/AILogAnalyser.py,sha256=TTbKOtz29OfgUkwq9KK7fpjcBzJ81qoR9487UQ-TT5o,7266
|
|
8
|
+
smartlayer/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
smartlayer/middleware/AIAnomalyDetector.py,sha256=ChxR2uEiXfxuy1bbwTMNe4PazUfx6vTPyVGkOOc42oY,14891
|
|
10
|
+
smartlayer/middleware/AIRequestValidator.py,sha256=NvGmE5_UP2WvNSX-a8twI281lEOND_EVdeXz2OQRb1M,5451
|
|
11
|
+
smartlayer/middleware/WatchLog.py,sha256=g8QRKANH5_v8x89J2K0Ot6CVcAjkGfpj3Hb8ti6Rs3A,2152
|
|
12
|
+
smartlayer/middleware/__init__.py,sha256=2KgnComJHYxre4ZhXptvPXxgLKMtWWYxA7W5MYKH0iU,279
|
|
13
|
+
smartlayer/middleware/rate_Limiter.py,sha256=PRh_eTaVrjuLrIwhpRe2P_I9Uo8HYj7Zk1jYRoRYE6s,4589
|
|
14
|
+
smartlayer/migrations/0001_initial.py,sha256=OGh_IWdQin6XptwRY4hQjvmhwRFaKMMLjawHRY9zg_0,3473
|
|
15
|
+
smartlayer/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
django_smart_layer-0.1.0.dist-info/METADATA,sha256=vmQG3yvLb24ApDX6pC8UfuqAUatFu-LhNuWcBH6IB_w,311
|
|
17
|
+
django_smart_layer-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
18
|
+
django_smart_layer-0.1.0.dist-info/top_level.txt,sha256=MpZh6zRTlIA0dC22Hd76rxnXSpEcz7m_e_VpeC_zW1Y,11
|
|
19
|
+
django_smart_layer-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
smartlayer
|
smartlayer/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
default_app_config = 'smartlayer.apps.SmartLayerConfig'
|
smartlayer/admin.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# smart_layer/admin.py
|
|
2
|
+
from django.contrib import admin
|
|
3
|
+
from smartlayer.models import DailyReport, RequestLog, BannedUser
|
|
4
|
+
|
|
5
|
+
@admin.register(DailyReport)
|
|
6
|
+
class DailyReportAdmin(admin.ModelAdmin):
|
|
7
|
+
list_display = ['date', 'created_at']
|
|
8
|
+
readonly_fields= ['date', 'report', 'created_at']
|
|
9
|
+
ordering = ['-date']
|
|
10
|
+
|
|
11
|
+
@admin.register(RequestLog)
|
|
12
|
+
class RequestLogAdmin(admin.ModelAdmin):
|
|
13
|
+
list_display = ['method', 'path', 'status_code', 'response_time_ms', 'user_id', 'was_blocked', 'timestamp']
|
|
14
|
+
readonly_fields= ['method', 'path', 'status_code', 'response_time_ms', 'user_id', 'ip_address', 'was_blocked', 'timestamp']
|
|
15
|
+
ordering = ['-timestamp']
|
|
16
|
+
|
|
17
|
+
@admin.register(BannedUser)
|
|
18
|
+
class BannedUserAdmin(admin.ModelAdmin):
|
|
19
|
+
list_display = ['ip_address', 'reason', 'banned_at', 'expires_at']
|
|
20
|
+
ordering = ['-banned_at']
|
smartlayer/apps.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SmartLayerConfig(AppConfig):
|
|
5
|
+
name = 'smartlayer' # ← fixed
|
|
6
|
+
|
|
7
|
+
def ready(self):
|
|
8
|
+
from django.conf import settings
|
|
9
|
+
config = getattr(settings, 'SMART_MIDDLEWARE', {})
|
|
10
|
+
|
|
11
|
+
schedule_time = config.get('ANALYSE_LOGS_AT')
|
|
12
|
+
if not schedule_time:
|
|
13
|
+
return
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from apscheduler.schedulers.background import BackgroundScheduler
|
|
17
|
+
from django.core.management import call_command
|
|
18
|
+
|
|
19
|
+
hour, minute = schedule_time.split(':')
|
|
20
|
+
|
|
21
|
+
scheduler = BackgroundScheduler()
|
|
22
|
+
scheduler.add_job(
|
|
23
|
+
lambda: call_command('analyse_logs'),
|
|
24
|
+
'cron',
|
|
25
|
+
hour=int(hour),
|
|
26
|
+
minute=int(minute),
|
|
27
|
+
id='smartlayer_analyse_logs',
|
|
28
|
+
replace_existing=True
|
|
29
|
+
)
|
|
30
|
+
scheduler.start()
|
|
31
|
+
|
|
32
|
+
except ImportError:
|
|
33
|
+
import warnings
|
|
34
|
+
warnings.warn( # ← added warning
|
|
35
|
+
"[Smart Layer] ANALYSE_LOGS_AT is set but apscheduler is not installed. "
|
|
36
|
+
"Run: pip install apscheduler "
|
|
37
|
+
"Or remove ANALYSE_LOGS_AT and use cron instead.",
|
|
38
|
+
RuntimeWarning
|
|
39
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Analyse API logs and generate a plain English summary.
|
|
3
|
+
|
|
4
|
+
Making it easy for developers to understand the overall health of their API,
|
|
5
|
+
identify slow endpoints, spot error patterns, and get actionable recommendations
|
|
6
|
+
without digging through raw log data.
|
|
7
|
+
|
|
8
|
+
Minimal configuration needed in settings.py:
|
|
9
|
+
|
|
10
|
+
SMART_MIDDLEWARE = {
|
|
11
|
+
'AI_API_KEY': 'your_ai_api_key',
|
|
12
|
+
'AI_BASE_URL': 'https://api.groq.com/openai/v1',
|
|
13
|
+
'AI_MODEL': 'llama3-8b-8192',
|
|
14
|
+
'ANALYSE_LOGS_AT': '06:00', # optional, default is 6am
|
|
15
|
+
'LOG_RETENTION_DAYS': 30, # optional, default is 30 days
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
Works with any OpenAI-compatible provider — Groq, OpenAI, Gemini, Ollama.
|
|
19
|
+
If no API key is provided or AI call fails, command still runs and provides
|
|
20
|
+
raw summary without AI insights — app never breaks because of us.
|
|
21
|
+
|
|
22
|
+
Schedule with cron (every morning at 6am):
|
|
23
|
+
0 6 * * * /path/to/venv/bin/python /path/to/manage.py analyse_logs
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from django.core.management.base import BaseCommand
|
|
27
|
+
from django.db.models import Avg, Count
|
|
28
|
+
from django.conf import settings
|
|
29
|
+
from datetime import date, timedelta, datetime
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Command(BaseCommand):
|
|
33
|
+
help = 'Analyse yesterday logs and save plain English report to database'
|
|
34
|
+
|
|
35
|
+
def handle(self, *args, **options):
|
|
36
|
+
|
|
37
|
+
from smartlayer.models import RequestLog, DailyReport
|
|
38
|
+
|
|
39
|
+
config = getattr(settings, 'SMART_MIDDLEWARE', {})
|
|
40
|
+
yesterday = date.today() - timedelta(days=1)
|
|
41
|
+
|
|
42
|
+
# ──Auto cleanup old logs ──────────────────────────────
|
|
43
|
+
retention_days = config.get('LOG_RETENTION_DAYS', 7)
|
|
44
|
+
cutoff = datetime.now() - timedelta(days=retention_days)
|
|
45
|
+
deleted_count, _ = RequestLog.objects.filter(timestamp__lt=cutoff).delete()
|
|
46
|
+
if deleted_count:
|
|
47
|
+
self.stdout.write(f"[Smart Layer] Cleaned up {deleted_count} logs older than {retention_days} days")
|
|
48
|
+
|
|
49
|
+
# ── Collect yesterday's stats ──────────────────────────
|
|
50
|
+
logs = RequestLog.objects.filter(timestamp__date=yesterday)
|
|
51
|
+
total = logs.count()
|
|
52
|
+
|
|
53
|
+
if total == 0:
|
|
54
|
+
self.stdout.write(f"[Smart Layer] No logs found for {yesterday}. Nothing to analyse.")
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
errors = logs.filter(status_code__gte=400).count()
|
|
58
|
+
blocked = logs.filter(was_blocked=True).count()
|
|
59
|
+
avg_time = logs.aggregate(Avg('response_time_ms'))['response_time_ms__avg'] or 0
|
|
60
|
+
|
|
61
|
+
slowest = (
|
|
62
|
+
logs.values('path')
|
|
63
|
+
.annotate(avg_time=Avg('response_time_ms'))
|
|
64
|
+
.order_by('-avg_time')[:5]
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
top_paths = (
|
|
68
|
+
logs.values('path')
|
|
69
|
+
.annotate(count=Count('path'))
|
|
70
|
+
.order_by('-count')[:5]
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
slowest_text = "\n".join([f" {r['path']}: {r['avg_time']:.1f}ms" for r in slowest])
|
|
74
|
+
top_paths_text = "\n".join([f" {r['path']}: {r['count']} hits" for r in top_paths])
|
|
75
|
+
|
|
76
|
+
# ── Build raw summary ───────────────────────────────────
|
|
77
|
+
summary = f"""
|
|
78
|
+
Date: {yesterday}
|
|
79
|
+
Total requests: {total}
|
|
80
|
+
Errors (4xx/5xx): {errors} ({(errors/total*100):.1f}%)
|
|
81
|
+
Avg response time: {avg_time:.1f}ms
|
|
82
|
+
Blocked requests: {blocked}
|
|
83
|
+
|
|
84
|
+
Top 5 slowest endpoints:
|
|
85
|
+
{slowest_text}
|
|
86
|
+
|
|
87
|
+
Top 5 most hit endpoints:
|
|
88
|
+
{top_paths_text}
|
|
89
|
+
""".strip()
|
|
90
|
+
|
|
91
|
+
# ── Asking AI to explain it in plain English ───────────────
|
|
92
|
+
ai_available = bool(
|
|
93
|
+
config.get('AI_API_KEY') and
|
|
94
|
+
config.get('AI_BASE_URL') and
|
|
95
|
+
config.get('AI_MODEL')
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if ai_available:
|
|
99
|
+
try:
|
|
100
|
+
from smartlayer.utils import ask_ai_text
|
|
101
|
+
|
|
102
|
+
prompt = f"""
|
|
103
|
+
You are an API monitoring expert reviewing a Django app's daily traffic report.
|
|
104
|
+
|
|
105
|
+
Write a plain English summary in 8-10 lines covering:
|
|
106
|
+
- Overall API health
|
|
107
|
+
- Error rate assessment
|
|
108
|
+
- Slowest endpoints and why they might be slow
|
|
109
|
+
- Anything suspicious or worth investigating
|
|
110
|
+
- 2-3 clear actionable recommendations
|
|
111
|
+
|
|
112
|
+
Keep it simple — the developer reading this is busy.
|
|
113
|
+
Do not repeat raw numbers, just explain what they mean.
|
|
114
|
+
|
|
115
|
+
Data:
|
|
116
|
+
{summary}
|
|
117
|
+
""".strip()
|
|
118
|
+
|
|
119
|
+
result = ask_ai_text(prompt, config)
|
|
120
|
+
|
|
121
|
+
final_report = f"""
|
|
122
|
+
{'='*60}
|
|
123
|
+
SMART LAYER — DAILY REPORT — {yesterday}
|
|
124
|
+
{'='*60}
|
|
125
|
+
|
|
126
|
+
{result}
|
|
127
|
+
|
|
128
|
+
{'─'*60}
|
|
129
|
+
RAW STATS:
|
|
130
|
+
{summary}
|
|
131
|
+
{'='*60}
|
|
132
|
+
""".strip()
|
|
133
|
+
|
|
134
|
+
except Exception as e:
|
|
135
|
+
# AI failed — fall back to raw summary, never crash
|
|
136
|
+
final_report = f"""
|
|
137
|
+
{'='*60}
|
|
138
|
+
SMART LAYER — DAILY REPORT — {yesterday}
|
|
139
|
+
{'='*60}
|
|
140
|
+
|
|
141
|
+
AI analysis unavailable ({str(e)})
|
|
142
|
+
|
|
143
|
+
RAW STATS:
|
|
144
|
+
{summary}
|
|
145
|
+
{'='*60}
|
|
146
|
+
""".strip()
|
|
147
|
+
|
|
148
|
+
else:
|
|
149
|
+
# no AI configured — just save raw summary
|
|
150
|
+
final_report = f"""
|
|
151
|
+
{'='*60}
|
|
152
|
+
SMART LAYER — DAILY REPORT — {yesterday}
|
|
153
|
+
{'='*60}
|
|
154
|
+
|
|
155
|
+
AI analysis not configured.
|
|
156
|
+
Add AI_API_KEY, AI_BASE_URL and AI_MODEL to SMART_MIDDLEWARE for plain English insights.
|
|
157
|
+
|
|
158
|
+
RAW STATS:
|
|
159
|
+
{summary}
|
|
160
|
+
{'='*60}
|
|
161
|
+
""".strip()
|
|
162
|
+
|
|
163
|
+
# ── Step 5: Save to DB ──────────────────────────────────────────
|
|
164
|
+
# saves to developer's existing database
|
|
165
|
+
# uses async write so this never blocks anything
|
|
166
|
+
report_obj, created = DailyReport.objects.update_or_create(
|
|
167
|
+
date=yesterday,
|
|
168
|
+
defaults={'report': final_report}
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
action = "Created" if created else "Updated"
|
|
172
|
+
self.stdout.write(f"[Smart Layer] {action} report for {yesterday} — visible in Django admin → Daily Reports")
|
|
173
|
+
|
|
174
|
+
# ── Step 6: Print to terminal too ──────────────────────────────
|
|
175
|
+
is_production = not config.get('DEBUG', True)
|
|
176
|
+
if not is_production:
|
|
177
|
+
self.stdout.write(final_report)
|
|
178
|
+
|
|
179
|
+
# always write a simple status line
|
|
180
|
+
# this goes to cron logs in production — short and clean
|
|
181
|
+
self.stdout.write(
|
|
182
|
+
f"[Smart Layer] Report for {yesterday} saved. "
|
|
183
|
+
f"Total: {total} requests, Errors: {errors}, Blocked: {blocked}"
|
|
184
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Global level anomaly detection middleware.
|
|
3
|
+
|
|
4
|
+
Detects anomalies in request patterns and blocks malicious requests.
|
|
5
|
+
Patterns are divided into 3 categories: BLACK, GREY, WHITE.
|
|
6
|
+
|
|
7
|
+
BLACK:
|
|
8
|
+
1. Empty user_agent → BLOCK
|
|
9
|
+
2. 50+ requests in last 10 seconds → BLOCK
|
|
10
|
+
3. 75%+ error rate in last 2 minutes (min 10 requests) → BLOCK
|
|
11
|
+
|
|
12
|
+
GREY:
|
|
13
|
+
Suspicion scoring system. If score >= threshold → ask AI.
|
|
14
|
+
AI receives raw behavioral data only (no score, no labels).
|
|
15
|
+
If score >= 8 → block immediately without AI call.
|
|
16
|
+
If score 4-7 → let request through, ask AI async, ban on next request.
|
|
17
|
+
|
|
18
|
+
WHITE:
|
|
19
|
+
Not BLACK, not GREY → let through.
|
|
20
|
+
|
|
21
|
+
Configuration in settings.py:
|
|
22
|
+
SMART_MIDDLEWARE = {
|
|
23
|
+
'AI_API_KEY': 'your_ai_api_key',
|
|
24
|
+
'AI_BASE_URL': 'https://api.groq.com/openai/v1',
|
|
25
|
+
'AI_MODEL': 'llama3-8b-8192',
|
|
26
|
+
'grey_suspicion_threshold': 4, # default 4
|
|
27
|
+
'grey_hard_block_score': 8, # default 8, block without AI
|
|
28
|
+
'grey_sensitive_paths': [ # optional, has defaults
|
|
29
|
+
'/admin', '/.env', '/config',
|
|
30
|
+
'/api/token', '/api/login',
|
|
31
|
+
],
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
Currently only supports GROQ. If no API key is provided or AI call fails,
|
|
35
|
+
middleware will still run and fall back to allowing the request through.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
import re
|
|
39
|
+
import threading
|
|
40
|
+
from datetime import timedelta
|
|
41
|
+
|
|
42
|
+
from django.conf import settings
|
|
43
|
+
from django.http import JsonResponse
|
|
44
|
+
from django.utils import timezone
|
|
45
|
+
|
|
46
|
+
from ..models import RequestLog, BannedUser
|
|
47
|
+
from ..utils import ask_ai_verdict
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
51
|
+
# CONSTANTS
|
|
52
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
53
|
+
|
|
54
|
+
DEFAULT_SENSITIVE_PATHS = [
|
|
55
|
+
'/admin', '/.env', '/config', '/phpmyadmin',
|
|
56
|
+
'/wp-admin', '/api/token', '/api/login',
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
SUSPICIOUS_UA_KEYWORDS = [
|
|
60
|
+
'curl', 'python-requests', 'go-http', 'java/',
|
|
61
|
+
'headlesschrome', 'phantomjs', 'scrapy', 'wget',
|
|
62
|
+
'libwww', 'httpx', 'okhttp', 'aiohttp',
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
# Minimum requests before we start scoring
|
|
66
|
+
# Protects new users exploring the site
|
|
67
|
+
NEW_USER_GRACE_LIMIT = 20
|
|
68
|
+
|
|
69
|
+
# Score weights
|
|
70
|
+
W_SUSPICIOUS_UA = 2
|
|
71
|
+
W_ELEVATED_RATE = 3 # 20-49 req/10s
|
|
72
|
+
W_MODERATE_ERROR_RATE = 2 # 40-74% errors in 2min
|
|
73
|
+
W_SENSITIVE_PATH = 4 # hitting /.env /admin etc
|
|
74
|
+
W_ENDPOINT_SCANNING = 2 # 15+ distinct paths/min
|
|
75
|
+
W_SEQUENTIAL_ID_PROBING = 5 # /users/1 /users/2 /users/3...
|
|
76
|
+
W_BURST_SAME_ENDPOINT = 2 # burst after idle, same endpoint
|
|
77
|
+
W_UNAUTHENTICATED = 1
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
81
|
+
# MIDDLEWARE
|
|
82
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
83
|
+
|
|
84
|
+
class AIAnomalyDetector:
|
|
85
|
+
|
|
86
|
+
def __init__(self, get_response):
|
|
87
|
+
self.get_response = get_response
|
|
88
|
+
cfg = getattr(settings, 'SMART_MIDDLEWARE', {})
|
|
89
|
+
self.grey_threshold = cfg.get('grey_suspicion_threshold', 4)
|
|
90
|
+
self.grey_hard_block = cfg.get('grey_hard_block_score', 8)
|
|
91
|
+
self.sensitive_paths = cfg.get('grey_sensitive_paths', DEFAULT_SENSITIVE_PATHS)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def __call__(self, request):
|
|
95
|
+
|
|
96
|
+
# ── BAN CHECK ────────────────────────────────────────────────────────
|
|
97
|
+
# For authenticated users, check by user_id.
|
|
98
|
+
# For anonymous users, check by IP (unauthenticated = no user_id to track).
|
|
99
|
+
ip = request.META.get('REMOTE_ADDR')
|
|
100
|
+
user_id = request.user.id if request.user.is_authenticated else None
|
|
101
|
+
|
|
102
|
+
if BannedUser.is_banned(user_id=user_id, ip_address=ip if not user_id else None):
|
|
103
|
+
request._was_blocked = True
|
|
104
|
+
return JsonResponse({"error": "blocked"}, status=403)
|
|
105
|
+
|
|
106
|
+
now = timezone.now()
|
|
107
|
+
|
|
108
|
+
# ── BLACK ────────────────────────────────────────────────────────────
|
|
109
|
+
block_response = self._black_check(request, now, user_id=user_id, ip=ip)
|
|
110
|
+
if block_response:
|
|
111
|
+
return block_response
|
|
112
|
+
|
|
113
|
+
# ── GREY ─────────────────────────────────────────────────────────────
|
|
114
|
+
grey_response = self._grey_check(request, now, user_id=user_id, ip=ip)
|
|
115
|
+
if grey_response:
|
|
116
|
+
return grey_response
|
|
117
|
+
|
|
118
|
+
# ── WHITE ─────────────────────────────────────────────────────────────
|
|
119
|
+
return self.get_response(request)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
123
|
+
# BLACK CHECKS
|
|
124
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
125
|
+
|
|
126
|
+
def _black_check(self, request, now, user_id, ip):
|
|
127
|
+
# Build the right filter: authenticated users by user_id, anon by IP
|
|
128
|
+
lookup = {'user_id': user_id} if user_id else {'ip_address': ip}
|
|
129
|
+
|
|
130
|
+
# 1. Empty user agent
|
|
131
|
+
if not request.META.get('HTTP_USER_AGENT'):
|
|
132
|
+
return self._block(request)
|
|
133
|
+
|
|
134
|
+
# 2. 50+ requests in last 10 seconds
|
|
135
|
+
last_10s = now - timedelta(seconds=10)
|
|
136
|
+
count_10s = RequestLog.objects.filter(
|
|
137
|
+
timestamp__gte=last_10s, **lookup
|
|
138
|
+
).count()
|
|
139
|
+
if count_10s >= 50:
|
|
140
|
+
return self._block(request)
|
|
141
|
+
|
|
142
|
+
# 3. 75%+ error rate in last 2 minutes (min 10 requests)
|
|
143
|
+
last_2min = now - timedelta(minutes=2)
|
|
144
|
+
qs_2min = RequestLog.objects.filter(timestamp__gte=last_2min, **lookup)
|
|
145
|
+
total_2min = qs_2min.count()
|
|
146
|
+
if total_2min >= 10:
|
|
147
|
+
error_count = qs_2min.filter(status_code__gte=400).count()
|
|
148
|
+
if (error_count / total_2min * 100) >= 75:
|
|
149
|
+
return self._block(request)
|
|
150
|
+
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
155
|
+
# GREY CHECKS
|
|
156
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
157
|
+
|
|
158
|
+
def _grey_check(self, request, now, user_id, ip):
|
|
159
|
+
score, payload = self._score_request(request, now, user_id=user_id, ip=ip)
|
|
160
|
+
|
|
161
|
+
if score <= 0:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
# Hard block — score so high AI is not needed
|
|
165
|
+
if score >= self.grey_hard_block:
|
|
166
|
+
return self._block(request)
|
|
167
|
+
|
|
168
|
+
# Soft grey — let request through, ask AI in background
|
|
169
|
+
if score >= self.grey_threshold:
|
|
170
|
+
thread = threading.Thread(
|
|
171
|
+
target=self._async_ai_check,
|
|
172
|
+
args=(user_id, ip, payload),
|
|
173
|
+
daemon=True
|
|
174
|
+
)
|
|
175
|
+
thread.start()
|
|
176
|
+
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _score_request(self, request, now, user_id, ip):
|
|
181
|
+
"""
|
|
182
|
+
Returns (score, raw_payload_for_ai).
|
|
183
|
+
Score is computed internally and never passed to AI.
|
|
184
|
+
Payload contains only raw behavioral facts.
|
|
185
|
+
"""
|
|
186
|
+
score = 0
|
|
187
|
+
lookup = {'user_id': user_id} if user_id else {'ip_address': ip}
|
|
188
|
+
|
|
189
|
+
# ── GRACE PERIOD: new users/IPs are not scored ───────────────────────
|
|
190
|
+
last_24h = now - timedelta(hours=24)
|
|
191
|
+
historical_count = RequestLog.objects.filter(
|
|
192
|
+
timestamp__gte=last_24h, **lookup
|
|
193
|
+
).count()
|
|
194
|
+
if historical_count < NEW_USER_GRACE_LIMIT:
|
|
195
|
+
return 0, {}
|
|
196
|
+
|
|
197
|
+
# ── FETCH RECENT DATA (shared across checks) ─────────────────────────
|
|
198
|
+
last_10s = now - timedelta(seconds=10)
|
|
199
|
+
last_1min = now - timedelta(minutes=1)
|
|
200
|
+
last_2min = now - timedelta(minutes=2)
|
|
201
|
+
last_30min = now - timedelta(minutes=30)
|
|
202
|
+
last_40min = now - timedelta(minutes=40)
|
|
203
|
+
|
|
204
|
+
count_10s = RequestLog.objects.filter(
|
|
205
|
+
timestamp__gte=last_10s, **lookup
|
|
206
|
+
).count()
|
|
207
|
+
|
|
208
|
+
qs_2min = RequestLog.objects.filter(
|
|
209
|
+
timestamp__gte=last_2min, **lookup
|
|
210
|
+
)
|
|
211
|
+
total_2min = qs_2min.count()
|
|
212
|
+
error_count = qs_2min.filter(status_code__gte=400).count() if total_2min else 0
|
|
213
|
+
error_rate = (error_count / total_2min * 100) if total_2min else 0
|
|
214
|
+
|
|
215
|
+
recent_paths = list(
|
|
216
|
+
RequestLog.objects.filter(
|
|
217
|
+
timestamp__gte=last_1min, **lookup
|
|
218
|
+
).values_list('path', flat=True)
|
|
219
|
+
)
|
|
220
|
+
distinct_paths = len(set(recent_paths))
|
|
221
|
+
|
|
222
|
+
ua = request.META.get('HTTP_USER_AGENT', '').lower()
|
|
223
|
+
|
|
224
|
+
# ── SCORING ───────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
# 1. Suspicious user agent
|
|
227
|
+
if any(kw in ua for kw in SUSPICIOUS_UA_KEYWORDS):
|
|
228
|
+
score += W_SUSPICIOUS_UA
|
|
229
|
+
|
|
230
|
+
# 2. Elevated rate (20-49/10s) - 50+ is already black
|
|
231
|
+
if 20 <= count_10s < 50:
|
|
232
|
+
score += W_ELEVATED_RATE
|
|
233
|
+
|
|
234
|
+
# 3. Moderate error rate (40-74%) - 75%+ is already black
|
|
235
|
+
if total_2min >= 10 and 40 <= error_rate < 75:
|
|
236
|
+
score += W_MODERATE_ERROR_RATE
|
|
237
|
+
|
|
238
|
+
# 4. Sensitive path checking
|
|
239
|
+
if any(request.path.startswith(p) for p in self.sensitive_paths):
|
|
240
|
+
score += W_SENSITIVE_PATH
|
|
241
|
+
|
|
242
|
+
# 5. Endpoint scanning - too many distinct paths in 1 min
|
|
243
|
+
if distinct_paths >= 15:
|
|
244
|
+
score += W_ENDPOINT_SCANNING
|
|
245
|
+
|
|
246
|
+
# 6. Sequential ID checking - /users/1 /users/2 /users/3
|
|
247
|
+
if self._is_sequential_probing(recent_paths):
|
|
248
|
+
score += W_SEQUENTIAL_ID_PROBING
|
|
249
|
+
|
|
250
|
+
# 7. Burst after long idle on same endpoint
|
|
251
|
+
was_idle = not RequestLog.objects.filter(
|
|
252
|
+
timestamp__gte=last_40min,
|
|
253
|
+
timestamp__lt=last_30min,
|
|
254
|
+
**lookup
|
|
255
|
+
).exists()
|
|
256
|
+
if was_idle and count_10s >= 15 and distinct_paths <= 2:
|
|
257
|
+
score += W_BURST_SAME_ENDPOINT
|
|
258
|
+
|
|
259
|
+
# 8. Unauthenticated user
|
|
260
|
+
if not request.user.is_authenticated:
|
|
261
|
+
score += W_UNAUTHENTICATED
|
|
262
|
+
|
|
263
|
+
# ── BUILD RAW PAYLOAD FOR AI (no score, no labels) ───────────────────
|
|
264
|
+
payload = {
|
|
265
|
+
"user_agent" : request.META.get('HTTP_USER_AGENT'),
|
|
266
|
+
"is_authenticated" : request.user.is_authenticated,
|
|
267
|
+
"current_path" : request.path,
|
|
268
|
+
"recent_endpoints" : recent_paths,
|
|
269
|
+
"request_count_10s" : count_10s,
|
|
270
|
+
"distinct_paths_1min" : distinct_paths,
|
|
271
|
+
"error_rate_2min" : round(error_rate, 2),
|
|
272
|
+
"total_requests_2min" : total_2min,
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return score, payload
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
279
|
+
# HELPERS
|
|
280
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
281
|
+
|
|
282
|
+
def _is_sequential_probing(self, paths, threshold=5):
|
|
283
|
+
"""
|
|
284
|
+
Detects if recent paths contain sequentially incrementing IDs.
|
|
285
|
+
e.g. /users/1 /users/2 /users/3 → True
|
|
286
|
+
/products/4 /products/89 /products/247 → False (random = human)
|
|
287
|
+
"""
|
|
288
|
+
id_pattern = re.compile(r'(\d+)$')
|
|
289
|
+
numbers = []
|
|
290
|
+
|
|
291
|
+
for path in paths:
|
|
292
|
+
match = id_pattern.search(path)
|
|
293
|
+
if match:
|
|
294
|
+
numbers.append(int(match.group(1)))
|
|
295
|
+
|
|
296
|
+
if len(numbers) < threshold:
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
numbers.sort()
|
|
300
|
+
consecutive = sum(
|
|
301
|
+
1 for i in range(1, len(numbers))
|
|
302
|
+
if numbers[i] - numbers[i - 1] == 1
|
|
303
|
+
)
|
|
304
|
+
return consecutive >= (threshold - 1)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _async_ai_check(self, user_id, ip_address, payload):
|
|
308
|
+
"""
|
|
309
|
+
Runs in background thread. Asks AI with raw payload only.
|
|
310
|
+
If AI says BLOCK, create a BannedUser row so the NEXT request is blocked.
|
|
311
|
+
|
|
312
|
+
Why next request and not this one?
|
|
313
|
+
Because this runs async — the current request has already been
|
|
314
|
+
let through (soft grey zone). Banning takes effect from now on.
|
|
315
|
+
"""
|
|
316
|
+
try:
|
|
317
|
+
cfg = getattr(settings, 'SMART_MIDDLEWARE', {})
|
|
318
|
+
verdict = ask_ai_verdict(payload, cfg)
|
|
319
|
+
if verdict == "BLOCK":
|
|
320
|
+
if user_id:
|
|
321
|
+
BannedUser.objects.get_or_create(
|
|
322
|
+
user_id=user_id,
|
|
323
|
+
defaults={'reason': 'AI anomaly detection (async)'}
|
|
324
|
+
)
|
|
325
|
+
elif ip_address:
|
|
326
|
+
BannedUser.objects.get_or_create(
|
|
327
|
+
ip_address=ip_address,
|
|
328
|
+
defaults={'reason': 'AI anomaly detection (async, anonymous)'}
|
|
329
|
+
)
|
|
330
|
+
except Exception:
|
|
331
|
+
pass # AI failure never blocks the request
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@staticmethod
|
|
335
|
+
def _block(request):
|
|
336
|
+
request._was_blocked = True
|
|
337
|
+
return JsonResponse({"error": "blocked"}, status=403)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""
|
|
2
|
+
It is a middleware that validates the request body.
|
|
3
|
+
Prevents SQL injection, XSS, Path Traversal, Shell Injection, Prompt Injection and other common attacks by looking for suspicious patterns in the request body.
|
|
4
|
+
It checks for suspicious patterns and if found 3 or more in the request then it blocks the request immediately without calling AI.
|
|
5
|
+
If it finds 1 or 2 suspicious patterns then it calls the AI to get a confidence score of how likely the request is malicious.
|
|
6
|
+
If the confidence score is above 85 then it blocks the request.
|
|
7
|
+
configuration needed in settings.py
|
|
8
|
+
SMART_MIDDLEWARE = {
|
|
9
|
+
'AI_API_KEY': 'your_ai_api_key',
|
|
10
|
+
'AI_BASE_URL': 'https://api.groq.com/openai/v1',
|
|
11
|
+
'AI_MODEL': 'llama3-8b-8192',
|
|
12
|
+
}
|
|
13
|
+
#currently only supports GROQ but can be extended to support other AI providers in future
|
|
14
|
+
if no api key is provided or if AI call fails for any reason, the middleware will fail open and allow the request to go through (to avoid breaking the app).
|
|
15
|
+
but will still block requests with 3 or more suspicious patterns without calling AI, to catch obvious attacks even if AI is not working.
|
|
16
|
+
"""
|
|
17
|
+
import re
|
|
18
|
+
from django.http import JsonResponse
|
|
19
|
+
from urllib.parse import unquote
|
|
20
|
+
import httpx
|
|
21
|
+
from ..utils import ask_ai_score
|
|
22
|
+
from django.conf import settings
|
|
23
|
+
|
|
24
|
+
SUSPICIOUS_PATTERNS = [
|
|
25
|
+
# SQL injection
|
|
26
|
+
r"(\bOR\b|\bAND\b)\s+\d+=\d+", # OR 1=1, AND 2=2
|
|
27
|
+
r"(UNION\s+SELECT|DROP\s+TABLE|DELETE\s+FROM|INSERT\s+INTO|UPDATE\s+SET)",
|
|
28
|
+
r"(--|;|\/\*|\*\/)\s*$", # SQL comments at end
|
|
29
|
+
r"'\s*(OR|AND)\s*'", # ' OR '
|
|
30
|
+
|
|
31
|
+
# Path traversal
|
|
32
|
+
r"\.\./|\.\.\\", # ../../
|
|
33
|
+
r"\/etc\/(passwd|shadow|hosts)", # /etc/passwd
|
|
34
|
+
r"\/proc\/self", # linux process files
|
|
35
|
+
|
|
36
|
+
# XSS
|
|
37
|
+
r"<script[\s>]", # <script>
|
|
38
|
+
r"javascript\s*:", # javascript:
|
|
39
|
+
r"on(error|load|click|mouseover)\s*=", # onerror= etc
|
|
40
|
+
|
|
41
|
+
# Shell injection
|
|
42
|
+
r"(;|\||&&)\s*(ls|cat|rm|wget|curl|bash|sh|python|perl)",
|
|
43
|
+
r"rm\s+-rf", # rm -rf
|
|
44
|
+
r"\$\(.*\)", # $(command)
|
|
45
|
+
r"`.*`", # `command`
|
|
46
|
+
|
|
47
|
+
# Prompt injection
|
|
48
|
+
r"ignore\s+(previous|all|prior)\s+instructions",
|
|
49
|
+
r"you\s+are\s+now\s+",
|
|
50
|
+
r"(pretend|act|behave)\s+(you\s+are|as\s+if)",
|
|
51
|
+
r"system\s*prompt",
|
|
52
|
+
r"jailbreak",
|
|
53
|
+
|
|
54
|
+
# Null bytes & encoding tricks
|
|
55
|
+
r"\x00", # null byte
|
|
56
|
+
r"%00", # null byte encoded
|
|
57
|
+
r"&#x?[0-9a-fA-F]+;", # HTML entities used to hide attacks
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
def normalize(body: str) -> str:
|
|
61
|
+
body = unquote(body) # decode URL encoding
|
|
62
|
+
body = body.lower() # normalize case
|
|
63
|
+
body = re.sub(r'\s+', ' ', body) # normalize whitespace
|
|
64
|
+
body = body.replace('\t', ' ') # tabs to spaces
|
|
65
|
+
return body
|
|
66
|
+
|
|
67
|
+
def suspicion_score(body: str) -> int:
|
|
68
|
+
normalized = normalize(body) # normalize first
|
|
69
|
+
score = 0
|
|
70
|
+
for pattern in SUSPICIOUS_PATTERNS:
|
|
71
|
+
if re.search(pattern, normalized): #re.search(pattern, string) looks for the pattern anywhere in the string
|
|
72
|
+
score += 1
|
|
73
|
+
return score
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
VALIDATION_PROMPT = """
|
|
77
|
+
You are a security expert analyzing HTTP request bodies.
|
|
78
|
+
|
|
79
|
+
The request has already passed basic pattern matching, so it contains NO obvious attacks.
|
|
80
|
+
Your job is to find CLEVER, HIDDEN, or OBFUSCATED attacks that bypass normal filters.
|
|
81
|
+
|
|
82
|
+
Look for:
|
|
83
|
+
- Encoded attacks (base64, hex, unicode)
|
|
84
|
+
- Attacks split across multiple fields
|
|
85
|
+
- Semantic prompt injection (doesn't use obvious phrases)
|
|
86
|
+
- Logic bombs hidden in normal-looking data
|
|
87
|
+
- Social engineering attempts
|
|
88
|
+
- Unusual data that could break parsers
|
|
89
|
+
- Business logic attacks (negative prices, impossible quantities)
|
|
90
|
+
|
|
91
|
+
Request body:
|
|
92
|
+
{body}
|
|
93
|
+
|
|
94
|
+
Reply with ONLY a number 0-100.
|
|
95
|
+
0 = definitely safe
|
|
96
|
+
100 = definitely malicious
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
class AIRequestValidator:
|
|
100
|
+
|
|
101
|
+
def __init__(self, get_response):
|
|
102
|
+
self.get_response = get_response
|
|
103
|
+
|
|
104
|
+
def __call__(self, request):
|
|
105
|
+
|
|
106
|
+
content_type = request.META.get('CONTENT_TYPE', '')
|
|
107
|
+
if 'multipart' in content_type:
|
|
108
|
+
return self.get_response(request) # skip binary file uploads
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
body = request.body.decode('utf-8')
|
|
112
|
+
except (UnicodeDecodeError, Exception):
|
|
113
|
+
return self.get_response(request) # can't decode - not a text attack
|
|
114
|
+
|
|
115
|
+
score = suspicion_score(body)
|
|
116
|
+
|
|
117
|
+
if score == 0: # clearly safe-- no AI call
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
elif score >= 3: # multiple patterns matched -- obviously malicious -- block immediately, no AI needed!
|
|
121
|
+
return JsonResponse({"error": "blocked"}, status=403)
|
|
122
|
+
|
|
123
|
+
elif score in (1, 2): # borderline -- ONLY these go to AI
|
|
124
|
+
try:
|
|
125
|
+
config = getattr(settings, 'SMART_MIDDLEWARE', {})
|
|
126
|
+
confidence = ask_ai_score(body, config,VALIDATION_PROMPT)
|
|
127
|
+
if confidence > 85:
|
|
128
|
+
return JsonResponse({"error": "blocked"}, status=403)
|
|
129
|
+
except Exception:
|
|
130
|
+
pass # if AI fails, let request through (don't break the app)
|
|
131
|
+
|
|
132
|
+
response = self.get_response(request)
|
|
133
|
+
return response
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#get_response = next bouncer
|
|
2
|
+
"""
|
|
3
|
+
WatchLog is a middleware that logs all requests to the database.
|
|
4
|
+
Basically populated RequestLog model with all the info about the request.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
from ..models import RequestLog
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
import threading
|
|
12
|
+
from ..models import RequestLog
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WatchLog:
|
|
16
|
+
def __init__(self, get_response):
|
|
17
|
+
self.get_response = get_response
|
|
18
|
+
|
|
19
|
+
def __call__(self, request):
|
|
20
|
+
start = time.monotonic()
|
|
21
|
+
response = self.get_response(request)
|
|
22
|
+
end = time.monotonic()
|
|
23
|
+
|
|
24
|
+
response_time_ms = (end - start) * 1000
|
|
25
|
+
|
|
26
|
+
# write in background — request returns immediately
|
|
27
|
+
threading.Thread(
|
|
28
|
+
target=self._save_log,
|
|
29
|
+
args=(request, response, response_time_ms),
|
|
30
|
+
daemon=True
|
|
31
|
+
).start()
|
|
32
|
+
|
|
33
|
+
return response
|
|
34
|
+
|
|
35
|
+
def _save_log(self, request, response, response_time_ms):
|
|
36
|
+
try:
|
|
37
|
+
RequestLog.objects.create(
|
|
38
|
+
user_id = request.user.id if request.user.is_authenticated else None,
|
|
39
|
+
ip_address = request.META.get('REMOTE_ADDR') if not request.user.is_authenticated else None,
|
|
40
|
+
method = request.method,
|
|
41
|
+
path = request.path,
|
|
42
|
+
status_code = response.status_code,
|
|
43
|
+
response_time_ms = response_time_ms,
|
|
44
|
+
was_blocked = getattr(request, '_was_blocked', False)
|
|
45
|
+
)
|
|
46
|
+
except Exception:
|
|
47
|
+
pass # never crash the app over a log write
|
|
48
|
+
|
|
49
|
+
#======================== Q&A ====================================================================
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
MIDDLEWARE = [
|
|
53
|
+
'django.middleware.security.SecurityMiddleware',
|
|
54
|
+
'smartlayer.middleware.AIRequestValidator', # 1st — block bad requests
|
|
55
|
+
'smartlayer.middleware.AIAnomalyDetector', # 2nd — detect patterns
|
|
56
|
+
'corsheaders.middleware.CorsMiddleware', # 3rd — CORS
|
|
57
|
+
'smartlayer.middleware.WatchLog', # 4th — log everything
|
|
58
|
+
'smartlayer.middleware.RateLimiter', # last — rate limit
|
|
59
|
+
]
|
|
60
|
+
"""
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .AIAnomalyDetector import AIAnomalyDetector
|
|
2
|
+
from .AIRequestValidator import AIRequestValidator
|
|
3
|
+
from .Rate_Limiter import RateLimiter
|
|
4
|
+
from .WatchLog import WatchLog
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
'AIAnomalyDetector',
|
|
8
|
+
'AIRequestValidator',
|
|
9
|
+
'RateLimiter',
|
|
10
|
+
'WatchLog',
|
|
11
|
+
]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RateLimiter is a middleware that limits requests to the backend.
|
|
3
|
+
Basically for implemeting Subscription Plans to the backend.
|
|
4
|
+
Highest Level of configuration it demands for -
|
|
5
|
+
SMART_MIDDLEWARE dict in settings.py
|
|
6
|
+
|
|
7
|
+
SMART_MIDDLEWARE={
|
|
8
|
+
'PLAN_FIELD': 'plan', # WHAT IS THE PLAN FIELD IN USER MODEL
|
|
9
|
+
'RATE_LIMIT_PLANS': {
|
|
10
|
+
'free': {
|
|
11
|
+
'/api/v1/users/1': {
|
|
12
|
+
'per_minute': 10,
|
|
13
|
+
'per_hour': 100,
|
|
14
|
+
'per_day': 1000,
|
|
15
|
+
'lifetime': 10000,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
'pro': {
|
|
19
|
+
'/api/v1/users/1': {
|
|
20
|
+
'per_minute': 20,
|
|
21
|
+
'per_hour': 200,
|
|
22
|
+
'per_day': 2000,
|
|
23
|
+
'lifetime': 20000,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
We provide a facilty to add rate limiting from lifetime -> per day -> per hour -> per minute.
|
|
30
|
+
Checking Scores for each of them and then limiting the request.
|
|
31
|
+
Scope checking Precedence -: Lifetime -> per day -> per hour -> per minute
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
from django.conf import settings
|
|
35
|
+
from django.core.cache import cache
|
|
36
|
+
from django.http import JsonResponse
|
|
37
|
+
from ..models import UserRequestCount
|
|
38
|
+
from django.db.models import F
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RateLimiter:
|
|
43
|
+
def __init__(self, get_response):
|
|
44
|
+
self.get_response = get_response
|
|
45
|
+
|
|
46
|
+
def __call__(self, request):
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
config = settings.SMART_MIDDLEWARE
|
|
50
|
+
except AttributeError:
|
|
51
|
+
return self.get_response(request) # no config - just let through
|
|
52
|
+
|
|
53
|
+
if not request.user.is_authenticated:
|
|
54
|
+
response = self.get_response(request)
|
|
55
|
+
return response
|
|
56
|
+
|
|
57
|
+
plan_field = config.get('PLAN_FIELD', 'plan') # default to 'plan'
|
|
58
|
+
user_plan = getattr(request.user, plan_field) # request.user.plan_filed like user.plan or user.subscription
|
|
59
|
+
|
|
60
|
+
limits = config['RATE_LIMIT_PLANS'].get(user_plan) #get all path limits
|
|
61
|
+
|
|
62
|
+
if limits is None: # plan not found in config - just let through
|
|
63
|
+
response = self.get_response(request)
|
|
64
|
+
return response
|
|
65
|
+
|
|
66
|
+
path_limits = limits.get(request.path) # limit for incoming path
|
|
67
|
+
|
|
68
|
+
if path_limits is None: # path not rate limited - let through
|
|
69
|
+
response = self.get_response(request)
|
|
70
|
+
return response
|
|
71
|
+
|
|
72
|
+
per_minute = path_limits.get('per_minute') # per minute limit
|
|
73
|
+
per_hour = path_limits.get('per_hour') # per hour limit
|
|
74
|
+
per_day = path_limits.get('per_day') # per day limit
|
|
75
|
+
lifetime = path_limits.get('lifetime') # lifetime of limit
|
|
76
|
+
|
|
77
|
+
if lifetime:
|
|
78
|
+
record, created = UserRequestCount.objects.get_or_create(user=request.user, path=request.path, plan_field=user_plan)
|
|
79
|
+
count = record.lifetime_count
|
|
80
|
+
if count >= lifetime:
|
|
81
|
+
request._was_blocked = True
|
|
82
|
+
return JsonResponse({"error": "Lifetime rate limit exceeded"}, status=429)
|
|
83
|
+
UserRequestCount.objects.filter(pk=record.pk).update(lifetime_count=F('lifetime_count') + 1) #to prevent race conditions -- concurrent requests causing multiple increments -- we do it in single query with F expression instead of fetching, incrementing and saving
|
|
84
|
+
|
|
85
|
+
if per_day:
|
|
86
|
+
key = f"rl:day:{request.user.id}:{user_plan}:{request.path}"
|
|
87
|
+
count = cache.get(key, 0)
|
|
88
|
+
if count >= per_day:
|
|
89
|
+
request._was_blocked = True
|
|
90
|
+
return JsonResponse({"error": "rate limit exceeded for today"}, status=429)
|
|
91
|
+
cache.set(key, count + 1, 3600*24)
|
|
92
|
+
|
|
93
|
+
if per_hour:
|
|
94
|
+
key = f"rl:hour:{request.user.id}:{user_plan}:{request.path}"
|
|
95
|
+
count = cache.get(key, 0)
|
|
96
|
+
if count >= per_hour:
|
|
97
|
+
request._was_blocked = True
|
|
98
|
+
return JsonResponse({"error": "rate limit exceeded for this hour"}, status=429)
|
|
99
|
+
cache.set(key, count + 1, 3600)
|
|
100
|
+
|
|
101
|
+
if per_minute:
|
|
102
|
+
key = f"rl:min:{request.user.id}:{user_plan}:{request.path}"
|
|
103
|
+
count = cache.get(key, 0)
|
|
104
|
+
if count >= per_minute:
|
|
105
|
+
request._was_blocked = True
|
|
106
|
+
return JsonResponse({"error": "too many requests per minute"}, status=429)
|
|
107
|
+
cache.set(key, count + 1, 60)
|
|
108
|
+
|
|
109
|
+
response=self.get_response(request)
|
|
110
|
+
return response
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Generated by Django 6.0.5 on 2026-06-07 07:15
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from django.db import migrations, models
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Migration(migrations.Migration):
|
|
9
|
+
|
|
10
|
+
initial = True
|
|
11
|
+
|
|
12
|
+
dependencies = [
|
|
13
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
operations = [
|
|
17
|
+
migrations.CreateModel(
|
|
18
|
+
name='DailyReport',
|
|
19
|
+
fields=[
|
|
20
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
21
|
+
('date', models.DateField(unique=True)),
|
|
22
|
+
('report', models.TextField()),
|
|
23
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
24
|
+
],
|
|
25
|
+
options={
|
|
26
|
+
'ordering': ['-date'],
|
|
27
|
+
},
|
|
28
|
+
),
|
|
29
|
+
migrations.CreateModel(
|
|
30
|
+
name='BannedUser',
|
|
31
|
+
fields=[
|
|
32
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
33
|
+
('user_id', models.IntegerField(blank=True, db_index=True, null=True)),
|
|
34
|
+
('ip_address', models.GenericIPAddressField(blank=True, db_index=True, null=True)),
|
|
35
|
+
('reason', models.TextField(default='AI anomaly detection')),
|
|
36
|
+
('banned_at', models.DateTimeField(auto_now_add=True)),
|
|
37
|
+
('expires_at', models.DateTimeField(blank=True, null=True)),
|
|
38
|
+
],
|
|
39
|
+
options={
|
|
40
|
+
'constraints': [models.UniqueConstraint(condition=models.Q(('user_id__isnull', False)), fields=('user_id',), name='unique_banned_user_id'), models.UniqueConstraint(condition=models.Q(('ip_address__isnull', False)), fields=('ip_address',), name='unique_banned_ip')],
|
|
41
|
+
},
|
|
42
|
+
),
|
|
43
|
+
migrations.CreateModel(
|
|
44
|
+
name='RequestLog',
|
|
45
|
+
fields=[
|
|
46
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
47
|
+
('user_id', models.IntegerField(blank=True, null=True)),
|
|
48
|
+
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
|
|
49
|
+
('method', models.CharField(max_length=10)),
|
|
50
|
+
('path', models.CharField(max_length=500)),
|
|
51
|
+
('status_code', models.IntegerField()),
|
|
52
|
+
('response_time_ms', models.FloatField()),
|
|
53
|
+
('timestamp', models.DateTimeField(auto_now_add=True)),
|
|
54
|
+
('was_blocked', models.BooleanField(default=False)),
|
|
55
|
+
],
|
|
56
|
+
options={
|
|
57
|
+
'indexes': [models.Index(fields=['user_id', 'timestamp'], name='reqlog_user_time_idx'), models.Index(fields=['timestamp'], name='reqlog_time_idx')],
|
|
58
|
+
},
|
|
59
|
+
),
|
|
60
|
+
migrations.CreateModel(
|
|
61
|
+
name='UserRequestCount',
|
|
62
|
+
fields=[
|
|
63
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
64
|
+
('path', models.CharField(max_length=200)),
|
|
65
|
+
('plan_field', models.CharField(max_length=50)),
|
|
66
|
+
('lifetime_count', models.IntegerField(default=0)),
|
|
67
|
+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
|
68
|
+
],
|
|
69
|
+
options={
|
|
70
|
+
'unique_together': {('user', 'path', 'plan_field')},
|
|
71
|
+
},
|
|
72
|
+
),
|
|
73
|
+
]
|
|
File without changes
|
smartlayer/models.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
|
|
2
|
+
from django.db import models
|
|
3
|
+
from django.contrib.auth import get_user_model
|
|
4
|
+
from django.utils import timezone
|
|
5
|
+
|
|
6
|
+
User = get_user_model()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RequestLog(models.Model):
|
|
10
|
+
user_id = models.IntegerField(null=True, blank=True)
|
|
11
|
+
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
|
12
|
+
method = models.CharField(max_length=10) # GET/POST/PUT/DELETE
|
|
13
|
+
path = models.CharField(max_length=500) # /api/v1/users/1
|
|
14
|
+
status_code = models.IntegerField() # 200/400/500
|
|
15
|
+
response_time_ms = models.FloatField() # time in ms
|
|
16
|
+
timestamp = models.DateTimeField(auto_now_add=True)
|
|
17
|
+
was_blocked = models.BooleanField(default=False) # blocked by any middleware
|
|
18
|
+
|
|
19
|
+
class Meta:
|
|
20
|
+
indexes = [
|
|
21
|
+
# Every anomaly detector query filters by (user_id, timestamp).
|
|
22
|
+
# Without this index those are full-table scans.
|
|
23
|
+
models.Index(fields=['user_id', 'timestamp'], name='reqlog_user_time_idx'),
|
|
24
|
+
# WatchLog and AILogAnalyser filter by date alone too.
|
|
25
|
+
models.Index(fields=['timestamp'], name='reqlog_time_idx'),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
def __str__(self):
|
|
29
|
+
return f"{self.method} {self.path}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BannedUser(models.Model):
|
|
33
|
+
"""
|
|
34
|
+
Written by AIAnomalyDetector when AI verdict is BLOCK.
|
|
35
|
+
Checked at the very start of every request — before any other logic runs.
|
|
36
|
+
|
|
37
|
+
Two kinds of bans:
|
|
38
|
+
- user_id ban : for authenticated users (checked by user id)
|
|
39
|
+
- ip_address ban: for anonymous users (checked by IP)
|
|
40
|
+
|
|
41
|
+
expires_at = None means permanent ban (until manually lifted).
|
|
42
|
+
"""
|
|
43
|
+
user_id = models.IntegerField(null=True, blank=True, db_index=True) #as may be user is unauthenticated, so we can't use ForeignKey to User model
|
|
44
|
+
ip_address = models.GenericIPAddressField(null=True, blank=True, db_index=True) #as may be user is authenticated, so we will not set ip`
|
|
45
|
+
reason = models.TextField(default='AI anomaly detection')
|
|
46
|
+
banned_at = models.DateTimeField(auto_now_add=True)
|
|
47
|
+
expires_at = models.DateTimeField(null=True, blank=True) # None = permanent
|
|
48
|
+
|
|
49
|
+
class Meta:
|
|
50
|
+
# Prevent duplicate ban rows for the same user/IP
|
|
51
|
+
constraints = [
|
|
52
|
+
models.UniqueConstraint( #UniqueConstraint says Only enforce uniqueness when the value exists.
|
|
53
|
+
fields=['user_id'],
|
|
54
|
+
condition=models.Q(user_id__isnull=False),
|
|
55
|
+
name='unique_banned_user_id'
|
|
56
|
+
),
|
|
57
|
+
models.UniqueConstraint(
|
|
58
|
+
fields=['ip_address'],
|
|
59
|
+
condition=models.Q(ip_address__isnull=False),
|
|
60
|
+
name='unique_banned_ip'
|
|
61
|
+
),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def is_banned(cls, user_id=None, ip_address=None):
|
|
66
|
+
"""
|
|
67
|
+
Returns True if this user_id or IP is currently banned.
|
|
68
|
+
Handles expiry automatically — expired bans are treated as not banned.
|
|
69
|
+
|
|
70
|
+
Usage:
|
|
71
|
+
BannedUser.is_banned(user_id=request.user.id)
|
|
72
|
+
BannedUser.is_banned(ip_address='1.2.3.4')
|
|
73
|
+
"""
|
|
74
|
+
now = timezone.now()
|
|
75
|
+
|
|
76
|
+
if user_id:
|
|
77
|
+
exists = cls.objects.filter(
|
|
78
|
+
user_id=user_id
|
|
79
|
+
).filter(
|
|
80
|
+
models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=now)
|
|
81
|
+
).exists()
|
|
82
|
+
if exists:
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
if ip_address:
|
|
86
|
+
exists = cls.objects.filter(
|
|
87
|
+
ip_address=ip_address
|
|
88
|
+
).filter(
|
|
89
|
+
models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=now)
|
|
90
|
+
).exists()
|
|
91
|
+
if exists:
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
def __str__(self):
|
|
97
|
+
target = f"user:{self.user_id}" if self.user_id else f"ip:{self.ip_address}"
|
|
98
|
+
return f"BannedUser({target}, expires={self.expires_at or 'never'})"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class UserRequestCount(models.Model):
|
|
102
|
+
user = models.ForeignKey('auth.User', on_delete=models.CASCADE)
|
|
103
|
+
path = models.CharField(max_length=200)
|
|
104
|
+
plan_field = models.CharField(max_length=50) #user.plan
|
|
105
|
+
lifetime_count = models.IntegerField(default=0)
|
|
106
|
+
|
|
107
|
+
class Meta:
|
|
108
|
+
unique_together = ['user', 'path', 'plan_field'] # one row per user per path per plan
|
|
109
|
+
|
|
110
|
+
class DailyReport(models.Model):
|
|
111
|
+
date = models.DateField(unique=True)
|
|
112
|
+
report = models.TextField()
|
|
113
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
114
|
+
|
|
115
|
+
class Meta:
|
|
116
|
+
app_label = 'smartlayer'
|
|
117
|
+
ordering = ['-date']
|
|
118
|
+
|
|
119
|
+
def __str__(self):
|
|
120
|
+
return f"Report — {self.date}"
|
smartlayer/utils.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
def call_ai(prompt: str, config: dict, max_tokens: int = 5, temperature: float = 0.0) -> str:
|
|
4
|
+
"""
|
|
5
|
+
Universal AI caller — works with any OpenAI-compatible provider.
|
|
6
|
+
|
|
7
|
+
Provider URLs:
|
|
8
|
+
Groq: https://api.groq.com/openai/v1
|
|
9
|
+
OpenAI: https://api.openai.com/v1
|
|
10
|
+
Gemini: https://generativelanguage.googleapis.com/v1beta/openai
|
|
11
|
+
Ollama: http://localhost:11434/v1
|
|
12
|
+
"""
|
|
13
|
+
api_key = config.get('AI_API_KEY')
|
|
14
|
+
base_url = config.get('AI_BASE_URL')
|
|
15
|
+
model = config.get('AI_MODEL')
|
|
16
|
+
|
|
17
|
+
if not all([api_key, base_url, model]):
|
|
18
|
+
raise ValueError("SMART_MIDDLEWARE must have AI_API_KEY, AI_BASE_URL and AI_MODEL")
|
|
19
|
+
|
|
20
|
+
response = httpx.post(
|
|
21
|
+
f"{base_url}/chat/completions",
|
|
22
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
23
|
+
json={
|
|
24
|
+
"model": model,
|
|
25
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
26
|
+
"max_tokens": max_tokens,
|
|
27
|
+
"temperature": temperature,
|
|
28
|
+
},
|
|
29
|
+
timeout=10.0
|
|
30
|
+
)
|
|
31
|
+
response.raise_for_status()
|
|
32
|
+
return response.json()["choices"][0]["message"]["content"].strip()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def ask_ai_score(body: str, config: dict, validation_prompt: str) -> int:
|
|
36
|
+
"""For AIRequestValidator — returns confidence score 0-100"""
|
|
37
|
+
prompt = validation_prompt.format(body=body[:500])
|
|
38
|
+
result = call_ai(prompt, config, max_tokens=5, temperature=0.0)
|
|
39
|
+
try:
|
|
40
|
+
return int(result)
|
|
41
|
+
except ValueError:
|
|
42
|
+
return 0 # if AI returns something unexpected, treat as safe
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def ask_ai_text(prompt: str, config: dict) -> str:
|
|
46
|
+
"""For AILogAnalyser — returns plain English text"""
|
|
47
|
+
return call_ai(prompt, config, max_tokens=500, temperature=0.7)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def ask_ai_verdict(payload: dict, config: dict) -> str:
|
|
51
|
+
"""For AIAnomalyDetector — returns BLOCK or ALLOW"""
|
|
52
|
+
import json
|
|
53
|
+
prompt = """
|
|
54
|
+
You are a security expert. Based on this raw API behaviour data, is this user a bot or attacker?
|
|
55
|
+
Data: {payload}
|
|
56
|
+
Reply with ONLY one word: BLOCK or ALLOW.
|
|
57
|
+
""".format(payload=json.dumps(payload, indent=2))
|
|
58
|
+
|
|
59
|
+
result = call_ai(prompt, config, max_tokens=5, temperature=0.0)
|
|
60
|
+
return "BLOCK" if "BLOCK" in result.upper() else "ALLOW"
|