aiwaf 0.1.9.1.9__py3-none-any.whl → 0.1.9.2.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.

Potentially problematic release.


This version of aiwaf might be problematic. Click here for more details.

aiwaf/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  default_app_config = "aiwaf.apps.AiwafConfig"
2
2
 
3
- __version__ = "0.1.9.1.9"
3
+ __version__ = "0.1.9.2.0"
4
4
 
5
5
  # Note: Middleware classes are available from aiwaf.middleware
6
6
  # Import them only when needed to avoid circular imports during Django app loading
@@ -1,76 +1,115 @@
1
1
  from django.core.management.base import BaseCommand
2
- from aiwaf.storage import get_blacklist_store, get_exemption_store
2
+ from aiwaf.storage import get_blacklist_store, get_exemption_store, get_keyword_store
3
3
  import sys
4
4
 
5
5
  class Command(BaseCommand):
6
- help = 'Reset AI-WAF by clearing all blacklist and exemption (whitelist) entries'
6
+ help = 'Reset AI-WAF by clearing blacklist, exemption, and/or keyword entries'
7
7
 
8
8
  def add_arguments(self, parser):
9
9
  parser.add_argument(
10
- '--blacklist-only',
10
+ '--blacklist',
11
11
  action='store_true',
12
- help='Clear only blacklist entries, keep exemptions'
12
+ help='Clear blacklist entries (default: all)'
13
13
  )
14
14
  parser.add_argument(
15
- '--exemptions-only',
15
+ '--exemptions',
16
+ action='store_true',
17
+ help='Clear exemption entries (default: all)'
18
+ )
19
+ parser.add_argument(
20
+ '--keywords',
16
21
  action='store_true',
17
- help='Clear only exemption entries, keep blacklist'
22
+ help='Clear learned dynamic keywords (default: all)'
18
23
  )
19
24
  parser.add_argument(
20
25
  '--confirm',
21
26
  action='store_true',
22
27
  help='Skip confirmation prompt'
23
28
  )
29
+
30
+ # Legacy flags for backward compatibility
31
+ parser.add_argument(
32
+ '--blacklist-only',
33
+ action='store_true',
34
+ help='(Legacy) Clear only blacklist entries'
35
+ )
36
+ parser.add_argument(
37
+ '--exemptions-only',
38
+ action='store_true',
39
+ help='(Legacy) Clear only exemption entries'
40
+ )
24
41
 
25
42
  def handle(self, *args, **options):
26
- blacklist_only = options['blacklist_only']
27
- exemptions_only = options['exemptions_only']
28
- confirm = options['confirm']
43
+ # Parse arguments
44
+ blacklist_flag = options.get('blacklist', False)
45
+ exemptions_flag = options.get('exemptions', False)
46
+ keywords_flag = options.get('keywords', False)
47
+ confirm = options.get('confirm', False)
48
+
49
+ # Legacy support
50
+ blacklist_only = options.get('blacklist_only', False)
51
+ exemptions_only = options.get('exemptions_only', False)
52
+
53
+ # Handle legacy flags
54
+ if blacklist_only:
55
+ blacklist_flag = True
56
+ exemptions_flag = False
57
+ keywords_flag = False
58
+ elif exemptions_only:
59
+ blacklist_flag = False
60
+ exemptions_flag = True
61
+ keywords_flag = False
62
+
63
+ # If no specific flags, clear everything
64
+ if not (blacklist_flag or exemptions_flag or keywords_flag):
65
+ blacklist_flag = exemptions_flag = keywords_flag = True
29
66
 
30
67
  try:
31
68
  blacklist_store = get_blacklist_store()
32
69
  exemption_store = get_exemption_store()
70
+ keyword_store = get_keyword_store()
33
71
  except Exception as e:
34
72
  self.stdout.write(self.style.ERROR(f'Error initializing stores: {e}'))
35
73
  return
36
74
 
37
75
  # Count current entries safely
38
- try:
39
- blacklist_entries = blacklist_store.get_all()
40
- blacklist_count = len(blacklist_entries)
41
- except Exception as e:
42
- self.stdout.write(self.style.WARNING(f'Warning: Could not count blacklist entries: {e}'))
43
- blacklist_count = 0
44
- blacklist_entries = []
76
+ counts = {'blacklist': 0, 'exemptions': 0, 'keywords': 0}
77
+ entries = {'blacklist': [], 'exemptions': [], 'keywords': []}
45
78
 
46
- try:
47
- exemption_entries = exemption_store.get_all()
48
- exemption_count = len(exemption_entries)
49
- except Exception as e:
50
- self.stdout.write(self.style.WARNING(f'Warning: Could not count exemption entries: {e}'))
51
- exemption_count = 0
52
- exemption_entries = []
79
+ if blacklist_flag:
80
+ try:
81
+ entries['blacklist'] = blacklist_store.get_all()
82
+ counts['blacklist'] = len(entries['blacklist'])
83
+ except Exception as e:
84
+ self.stdout.write(self.style.WARNING(f'Warning: Could not count blacklist entries: {e}'))
53
85
 
54
- if blacklist_only and exemptions_only:
55
- self.stdout.write(self.style.ERROR('Cannot use both --blacklist-only and --exemptions-only flags'))
56
- return
86
+ if exemptions_flag:
87
+ try:
88
+ entries['exemptions'] = exemption_store.get_all()
89
+ counts['exemptions'] = len(entries['exemptions'])
90
+ except Exception as e:
91
+ self.stdout.write(self.style.WARNING(f'Warning: Could not count exemption entries: {e}'))
57
92
 
58
- # Determine what to clear
59
- if blacklist_only:
60
- action = f"Clear {blacklist_count} blacklist entries"
61
- clear_blacklist = True
62
- clear_exemptions = False
63
- elif exemptions_only:
64
- action = f"Clear {exemption_count} exemption entries"
65
- clear_blacklist = False
66
- clear_exemptions = True
67
- else:
68
- action = f"Clear {blacklist_count} blacklist entries and {exemption_count} exemption entries"
69
- clear_blacklist = True
70
- clear_exemptions = True
93
+ if keywords_flag:
94
+ try:
95
+ entries['keywords'] = keyword_store.get_all_keywords()
96
+ counts['keywords'] = len(entries['keywords'])
97
+ except Exception as e:
98
+ self.stdout.write(self.style.WARNING(f'Warning: Could not count keyword entries: {e}'))
99
+
100
+ # Build action description
101
+ actions = []
102
+ if blacklist_flag:
103
+ actions.append(f"{counts['blacklist']} blacklist entries")
104
+ if exemptions_flag:
105
+ actions.append(f"{counts['exemptions']} exemption entries")
106
+ if keywords_flag:
107
+ actions.append(f"{counts['keywords']} learned keywords")
108
+
109
+ action = "Clear " + ", ".join(actions)
71
110
 
72
111
  # Show what will be cleared
73
- self.stdout.write(f"AI-WAF Reset: {action}")
112
+ self.stdout.write(f"🔧 AI-WAF Reset: {action}")
74
113
 
75
114
  if not confirm:
76
115
  try:
@@ -83,12 +122,12 @@ class Command(BaseCommand):
83
122
  return
84
123
 
85
124
  # Perform the reset
86
- deleted_counts = {'blacklist': 0, 'exemptions': 0, 'errors': []}
125
+ deleted_counts = {'blacklist': 0, 'exemptions': 0, 'keywords': 0, 'errors': []}
87
126
 
88
- if clear_blacklist:
127
+ if blacklist_flag:
89
128
  # Clear blacklist entries
90
129
  try:
91
- for entry in blacklist_entries:
130
+ for entry in entries['blacklist']:
92
131
  try:
93
132
  blacklist_store.remove_ip(entry['ip_address'])
94
133
  deleted_counts['blacklist'] += 1
@@ -97,10 +136,10 @@ class Command(BaseCommand):
97
136
  except Exception as e:
98
137
  deleted_counts['errors'].append(f"Error clearing blacklist: {e}")
99
138
 
100
- if clear_exemptions:
139
+ if exemptions_flag:
101
140
  # Clear exemption entries
102
141
  try:
103
- for entry in exemption_entries:
142
+ for entry in entries['exemptions']:
104
143
  try:
105
144
  exemption_store.remove_ip(entry['ip_address'])
106
145
  deleted_counts['exemptions'] += 1
@@ -109,26 +148,34 @@ class Command(BaseCommand):
109
148
  except Exception as e:
110
149
  deleted_counts['errors'].append(f"Error clearing exemptions: {e}")
111
150
 
151
+ if keywords_flag:
152
+ # Clear keyword entries
153
+ try:
154
+ for keyword in entries['keywords']:
155
+ try:
156
+ keyword_store.remove_keyword(keyword)
157
+ deleted_counts['keywords'] += 1
158
+ except Exception as e:
159
+ deleted_counts['errors'].append(f"Error removing keyword '{keyword}': {e}")
160
+ except Exception as e:
161
+ deleted_counts['errors'].append(f"Error clearing keywords: {e}")
162
+
112
163
  # Report results
113
164
  if deleted_counts['errors']:
114
165
  for error in deleted_counts['errors']:
115
166
  self.stdout.write(self.style.WARNING(f"⚠️ {error}"))
116
167
 
117
- if clear_blacklist and clear_exemptions:
118
- self.stdout.write(
119
- self.style.SUCCESS(
120
- f"✅ Reset complete: Deleted {deleted_counts['blacklist']} blacklist entries "
121
- f"and {deleted_counts['exemptions']} exemption entries"
122
- )
123
- )
124
- elif clear_blacklist:
125
- self.stdout.write(
126
- self.style.SUCCESS(f"✅ Blacklist cleared: Deleted {deleted_counts['blacklist']} entries")
127
- )
128
- elif clear_exemptions:
129
- self.stdout.write(
130
- self.style.SUCCESS(f"✅ Exemptions cleared: Deleted {deleted_counts['exemptions']} entries")
131
- )
168
+ # Build success message
169
+ success_parts = []
170
+ if blacklist_flag:
171
+ success_parts.append(f"{deleted_counts['blacklist']} blacklist entries")
172
+ if exemptions_flag:
173
+ success_parts.append(f"{deleted_counts['exemptions']} exemption entries")
174
+ if keywords_flag:
175
+ success_parts.append(f"{deleted_counts['keywords']} learned keywords")
176
+
177
+ success_message = "✅ Reset complete: Deleted " + ", ".join(success_parts)
178
+ self.stdout.write(self.style.SUCCESS(success_message))
132
179
 
133
180
  if deleted_counts['errors']:
134
181
  self.stdout.write(
aiwaf/middleware.py CHANGED
@@ -3,6 +3,7 @@
3
3
  import time
4
4
  import re
5
5
  import os
6
+ import warnings
6
7
  import numpy as np
7
8
  import joblib
8
9
  from django.db.models import UUIDField
@@ -82,6 +83,93 @@ class IPAndKeywordBlockMiddleware:
82
83
  def __init__(self, get_response):
83
84
  self.get_response = get_response
84
85
  self.safe_prefixes = self._collect_safe_prefixes()
86
+ self.exempt_keywords = self._get_exempt_keywords()
87
+ self.legitimate_path_keywords = self._get_legitimate_path_keywords()
88
+
89
+ def _get_exempt_keywords(self):
90
+ """Get keywords that should be exempt from blocking"""
91
+ exempt_tokens = set()
92
+
93
+ # Extract from exempt paths
94
+ for path in getattr(settings, "AIWAF_EXEMPT_PATHS", []):
95
+ for seg in re.split(r"\W+", path.strip("/").lower()):
96
+ if len(seg) > 3:
97
+ exempt_tokens.add(seg)
98
+
99
+ # Add explicit exempt keywords from settings
100
+ exempt_keywords = getattr(settings, "AIWAF_EXEMPT_KEYWORDS", [])
101
+ exempt_tokens.update(exempt_keywords)
102
+
103
+ return exempt_tokens
104
+
105
+ def _get_legitimate_path_keywords(self):
106
+ """Get keywords that are legitimate in URL paths"""
107
+ # Extract from Django URL patterns
108
+ legitimate_keywords = set()
109
+
110
+ # Add common legitimate path segments
111
+ default_legitimate = {
112
+ "profile", "user", "account", "settings", "dashboard",
113
+ "home", "about", "contact", "help", "search", "list",
114
+ "view", "edit", "create", "update", "delete", "detail",
115
+ "api", "auth", "login", "logout", "register", "signup",
116
+ "reset", "confirm", "activate", "verify", "page",
117
+ "category", "tag", "post", "article", "blog", "news"
118
+ }
119
+ legitimate_keywords.update(default_legitimate)
120
+
121
+ # Add from Django settings
122
+ allowed_path_keywords = getattr(settings, "AIWAF_ALLOWED_PATH_KEYWORDS", [])
123
+ legitimate_keywords.update(allowed_path_keywords)
124
+
125
+ # Extract from actual Django URL patterns
126
+ resolver = get_resolver()
127
+ self._extract_path_keywords_from_urls(resolver.url_patterns, legitimate_keywords)
128
+
129
+ return legitimate_keywords
130
+
131
+ def _extract_path_keywords_from_urls(self, url_patterns, keywords, prefix=""):
132
+ """Extract legitimate keywords from Django URL patterns"""
133
+ for pattern in url_patterns:
134
+ if hasattr(pattern, 'url_patterns'): # include()
135
+ new_prefix = prefix + str(pattern.pattern).strip('^$/')
136
+ self._extract_path_keywords_from_urls(pattern.url_patterns, keywords, new_prefix)
137
+ else:
138
+ # Extract static path segments from URL pattern
139
+ pattern_str = str(pattern.pattern).strip('^$/')
140
+ full_path = (prefix + '/' + pattern_str).strip('/')
141
+
142
+ # Extract meaningful segments (not regex patterns)
143
+ segments = re.findall(r'[a-zA-Z]{3,}', full_path)
144
+ for seg in segments:
145
+ if seg.lower() not in {'http', 'https', 'www'}:
146
+ keywords.add(seg.lower())
147
+
148
+ def _is_malicious_context(self, request, segment):
149
+ """Determine if a keyword appears in a malicious context"""
150
+ path = request.path.lower()
151
+
152
+ # Check if this is a query parameter attack
153
+ query_string = request.META.get('QUERY_STRING', '').lower()
154
+ if segment in query_string and any(attack_pattern in query_string for attack_pattern in [
155
+ 'union', 'select', 'drop', 'insert', 'script', 'alert', 'eval'
156
+ ]):
157
+ return True
158
+
159
+ # Check if this looks like a file extension attack
160
+ if segment.startswith('.') and not path_exists_in_django(request.path):
161
+ return True
162
+
163
+ # Check if this looks like a directory traversal
164
+ if '../' in path or '..\\' in path:
165
+ return True
166
+
167
+ # Check if accessing non-existent paths with suspicious extensions
168
+ if (not path_exists_in_django(request.path) and
169
+ any(ext in segment for ext in ['.php', '.asp', '.jsp', '.cgi'])):
170
+ return True
171
+
172
+ return False
85
173
 
86
174
  def _collect_safe_prefixes(self):
87
175
  resolver = get_resolver()
@@ -102,35 +190,68 @@ class IPAndKeywordBlockMiddleware:
102
190
  return prefixes
103
191
 
104
192
  def __call__(self, request):
105
- raw_path = request.path.lower()
193
+ # First exemption check - early exit for exempt requests
106
194
  if is_exempt(request):
107
195
  return self.get_response(request)
196
+
197
+ raw_path = request.path.lower()
108
198
  ip = get_ip(request)
109
199
  path = raw_path.lstrip("/")
110
200
 
111
- # BlacklistManager now handles exemption checking internally
201
+ # Additional IP-level exemption check
202
+ from .storage import get_exemption_store
203
+ exemption_store = get_exemption_store()
204
+ if exemption_store.is_exempted(ip):
205
+ return self.get_response(request)
206
+
207
+ # BlacklistManager handles exemption checking internally
112
208
  if BlacklistManager.is_blocked(ip):
113
209
  return JsonResponse({"error": "blocked"}, status=403)
114
210
 
211
+ # Check if path exists in Django - if yes, be more lenient
212
+ path_exists = path_exists_in_django(request.path)
213
+
115
214
  keyword_store = get_keyword_store()
116
215
  segments = [seg for seg in re.split(r"\W+", path) if len(seg) > 3]
117
216
 
217
+ # Only learn keywords from non-existent paths or suspicious contexts
118
218
  for seg in segments:
119
- keyword_store.add_keyword(seg)
219
+ if not path_exists or self._is_malicious_context(request, seg):
220
+ keyword_store.add_keyword(seg)
120
221
 
121
222
  dynamic_top = keyword_store.get_top_keywords(getattr(settings, "AIWAF_DYNAMIC_TOP_N", 10))
122
223
  all_kw = set(STATIC_KW) | set(dynamic_top)
123
- suspicious_kw = {
124
- kw for kw in all_kw
125
- if not any(path.startswith(prefix) for prefix in self.safe_prefixes if prefix)
126
- }
224
+
225
+ # Enhanced filtering logic
226
+ suspicious_kw = set()
227
+ for kw in all_kw:
228
+ # Skip if keyword is explicitly exempted
229
+ if kw in self.exempt_keywords:
230
+ continue
231
+
232
+ # Skip if this is a legitimate path keyword and path exists in Django
233
+ if (kw in self.legitimate_path_keywords and
234
+ path_exists and
235
+ not self._is_malicious_context(request, kw)):
236
+ continue
237
+
238
+ # Skip if path starts with safe prefix
239
+ if any(path.startswith(prefix) for prefix in self.safe_prefixes if prefix):
240
+ continue
241
+
242
+ suspicious_kw.add(kw)
243
+
244
+ # Check segments against suspicious keywords
127
245
  for seg in segments:
128
246
  if seg in suspicious_kw:
129
- # BlacklistManager.block() now checks exemptions internally
130
- BlacklistManager.block(ip, f"Keyword block: {seg}")
131
- # Check again after blocking attempt (exempted IPs won't be blocked)
132
- if BlacklistManager.is_blocked(ip):
133
- return JsonResponse({"error": "blocked"}, status=403)
247
+ # Additional context check before blocking
248
+ if self._is_malicious_context(request, seg) or not path_exists:
249
+ # Double-check exemption before blocking
250
+ if not exemption_store.is_exempted(ip):
251
+ BlacklistManager.block(ip, f"Keyword block: {seg} (context: malicious)")
252
+ # Check again after blocking attempt (exempted IPs won't be blocked)
253
+ if BlacklistManager.is_blocked(ip):
254
+ return JsonResponse({"error": "blocked"}, status=403)
134
255
  return self.get_response(request)
135
256
 
136
257
 
@@ -143,22 +264,32 @@ class RateLimitMiddleware:
143
264
  self.FLOOD = getattr(settings, "AIWAF_RATE_FLOOD", 40) # hard limit
144
265
 
145
266
  def __call__(self, request):
267
+ # First exemption check - early exit for exempt requests
146
268
  if is_exempt(request):
147
269
  return self.get_response(request)
148
270
 
149
271
  ip = get_ip(request)
272
+
273
+ # Additional IP-level exemption check
274
+ from .storage import get_exemption_store
275
+ exemption_store = get_exemption_store()
276
+ if exemption_store.is_exempted(ip):
277
+ return self.get_response(request)
278
+
150
279
  key = f"ratelimit:{ip}"
151
280
  now = time.time()
152
281
  timestamps = cache.get(key, [])
153
282
  timestamps = [t for t in timestamps if now - t < self.WINDOW]
154
283
  timestamps.append(now)
155
284
  cache.set(key, timestamps, timeout=self.WINDOW)
285
+
156
286
  if len(timestamps) > self.FLOOD:
157
- # BlacklistManager.block() now checks exemptions internally
158
- BlacklistManager.block(ip, "Flood pattern")
159
- # Check if actually blocked (exempted IPs won't be blocked)
160
- if BlacklistManager.is_blocked(ip):
161
- return JsonResponse({"error": "blocked"}, status=403)
287
+ # Double-check exemption before blocking
288
+ if not exemption_store.is_exempted(ip):
289
+ BlacklistManager.block(ip, "Flood pattern")
290
+ # Check if actually blocked (exempted IPs won't be blocked)
291
+ if BlacklistManager.is_blocked(ip):
292
+ return JsonResponse({"error": "blocked"}, status=403)
162
293
  if len(timestamps) > self.MAX:
163
294
  return JsonResponse({"error": "too_many_requests"}, status=429)
164
295
  return self.get_response(request)
@@ -174,19 +305,37 @@ class AIAnomalyMiddleware(MiddlewareMixin):
174
305
  self.model = MODEL
175
306
 
176
307
  def process_request(self, request):
308
+ # First exemption check - early exit for exempt requests
177
309
  if is_exempt(request):
178
310
  return None
311
+
179
312
  request._start_time = time.time()
180
313
  ip = get_ip(request)
181
- # BlacklistManager now handles exemption checking internally
314
+
315
+ # Additional IP-level exemption check
316
+ from .storage import get_exemption_store
317
+ exemption_store = get_exemption_store()
318
+ if exemption_store.is_exempted(ip):
319
+ return None
320
+
321
+ # BlacklistManager handles exemption checking internally
182
322
  if BlacklistManager.is_blocked(ip):
183
323
  return JsonResponse({"error": "blocked"}, status=403)
184
324
  return None
185
325
 
186
326
  def process_response(self, request, response):
327
+ # First exemption check - early exit for exempt requests
187
328
  if is_exempt(request):
188
329
  return response
330
+
189
331
  ip = get_ip(request)
332
+
333
+ # Additional IP-level exemption check
334
+ from .storage import get_exemption_store
335
+ exemption_store = get_exemption_store()
336
+ if exemption_store.is_exempted(ip):
337
+ return response
338
+
190
339
  now = time.time()
191
340
  key = f"aiwaf:{ip}"
192
341
  data = cache.get(key, [])
@@ -251,17 +400,20 @@ class AIAnomalyMiddleware(MiddlewareMixin):
251
400
  # Anomalous but looks legitimate - don't block
252
401
  pass
253
402
  else:
254
- # Block if it shows clear signs of malicious behavior
255
- BlacklistManager.block(ip, f"AI anomaly + suspicious patterns (kw:{avg_kw_hits:.1f}, 404s:{max_404s}, burst:{avg_burst:.1f})")
256
- # Check if actually blocked (exempted IPs won't be blocked)
257
- if BlacklistManager.is_blocked(ip):
258
- return JsonResponse({"error": "blocked"}, status=403)
403
+ # Double-check exemption before blocking
404
+ if not exemption_store.is_exempted(ip):
405
+ BlacklistManager.block(ip, f"AI anomaly + suspicious patterns (kw:{avg_kw_hits:.1f}, 404s:{max_404s}, burst:{avg_burst:.1f})")
406
+ # Check if actually blocked (exempted IPs won't be blocked)
407
+ if BlacklistManager.is_blocked(ip):
408
+ return JsonResponse({"error": "blocked"}, status=403)
259
409
  else:
260
410
  # No recent data to analyze - be more conservative, only block on very suspicious current request
261
411
  if kw_hits >= 2 or status_idx == STATUS_IDX.index("404"):
262
- BlacklistManager.block(ip, "AI anomaly + immediate suspicious behavior")
263
- if BlacklistManager.is_blocked(ip):
264
- return JsonResponse({"error": "blocked"}, status=403)
412
+ # Double-check exemption before blocking
413
+ if not exemption_store.is_exempted(ip):
414
+ BlacklistManager.block(ip, "AI anomaly + immediate suspicious behavior")
415
+ if BlacklistManager.is_blocked(ip):
416
+ return JsonResponse({"error": "blocked"}, status=403)
265
417
 
266
418
  data.append((now, request.path, response.status_code, resp_time))
267
419
  data = [d for d in data if now - d[0] < self.WINDOW]
@@ -283,7 +435,12 @@ class HoneypotTimingMiddleware(MiddlewareMixin):
283
435
  return None
284
436
 
285
437
  ip = get_ip(request)
286
- # BlacklistManager now handles exemption checking internally
438
+
439
+ # Additional IP-level exemption check
440
+ from .storage import get_exemption_store
441
+ exemption_store = get_exemption_store()
442
+ if exemption_store.is_exempted(ip):
443
+ return None
287
444
 
288
445
  if request.method == "GET":
289
446
  # Store timestamp for this IP's GET request
@@ -300,11 +457,12 @@ class HoneypotTimingMiddleware(MiddlewareMixin):
300
457
  if not any(request.path.lower().startswith(login_path) for login_path in [
301
458
  "/admin/login/", "/login/", "/accounts/login/", "/auth/login/", "/signin/"
302
459
  ]):
303
- # BlacklistManager.block() now checks exemptions internally
304
- BlacklistManager.block(ip, "Direct POST without GET")
305
- # Check if actually blocked (exempted IPs won't be blocked)
306
- if BlacklistManager.is_blocked(ip):
307
- return JsonResponse({"error": "blocked"}, status=403)
460
+ # Double-check exemption before blocking
461
+ if not exemption_store.is_exempted(ip):
462
+ BlacklistManager.block(ip, "Direct POST without GET")
463
+ # Check if actually blocked (exempted IPs won't be blocked)
464
+ if BlacklistManager.is_blocked(ip):
465
+ return JsonResponse({"error": "blocked"}, status=403)
308
466
  else:
309
467
  # Check timing - be more lenient for login paths
310
468
  time_diff = time.time() - get_time
@@ -317,11 +475,12 @@ class HoneypotTimingMiddleware(MiddlewareMixin):
317
475
  min_time = 0.1 # Very short threshold for login forms
318
476
 
319
477
  if time_diff < min_time:
320
- # BlacklistManager.block() now checks exemptions internally
321
- BlacklistManager.block(ip, f"Form submitted too quickly ({time_diff:.2f}s)")
322
- # Check if actually blocked (exempted IPs won't be blocked)
323
- if BlacklistManager.is_blocked(ip):
324
- return JsonResponse({"error": "blocked"}, status=403)
478
+ # Double-check exemption before blocking
479
+ if not exemption_store.is_exempted(ip):
480
+ BlacklistManager.block(ip, f"Form submitted too quickly ({time_diff:.2f}s)")
481
+ # Check if actually blocked (exempted IPs won't be blocked)
482
+ if BlacklistManager.is_blocked(ip):
483
+ return JsonResponse({"error": "blocked"}, status=403)
325
484
 
326
485
  return None
327
486
 
@@ -330,11 +489,19 @@ class UUIDTamperMiddleware(MiddlewareMixin):
330
489
  def process_view(self, request, view_func, view_args, view_kwargs):
331
490
  if is_exempt(request):
332
491
  return None
492
+
333
493
  uid = view_kwargs.get("uuid")
334
494
  if not uid:
335
495
  return None
336
496
 
337
497
  ip = get_ip(request)
498
+
499
+ # Additional IP-level exemption check
500
+ from .storage import get_exemption_store
501
+ exemption_store = get_exemption_store()
502
+ if exemption_store.is_exempted(ip):
503
+ return None
504
+
338
505
  app_label = view_func.__module__.split(".")[0]
339
506
  app_cfg = apps.get_app_config(app_label)
340
507
  for Model in app_cfg.get_models():
@@ -345,8 +512,9 @@ class UUIDTamperMiddleware(MiddlewareMixin):
345
512
  except (ValueError, TypeError):
346
513
  continue
347
514
 
348
- # BlacklistManager.block() now checks exemptions internally
349
- BlacklistManager.block(ip, "UUID tampering")
350
- # Check if actually blocked (exempted IPs won't be blocked)
351
- if BlacklistManager.is_blocked(ip):
352
- return JsonResponse({"error": "blocked"}, status=403)
515
+ # Double-check exemption before blocking
516
+ if not exemption_store.is_exempted(ip):
517
+ BlacklistManager.block(ip, "UUID tampering")
518
+ # Check if actually blocked (exempted IPs won't be blocked)
519
+ if BlacklistManager.is_blocked(ip):
520
+ return JsonResponse({"error": "blocked"}, status=403)
aiwaf/storage.py CHANGED
@@ -284,6 +284,19 @@ class ModelKeywordStore:
284
284
  except Exception:
285
285
  return []
286
286
 
287
+ @staticmethod
288
+ def get_all_keywords():
289
+ """Get all keywords"""
290
+ _import_models()
291
+ if DynamicKeyword is None:
292
+ return []
293
+ try:
294
+ return list(
295
+ DynamicKeyword.objects.all().values_list('keyword', flat=True)
296
+ )
297
+ except Exception:
298
+ return []
299
+
287
300
  @staticmethod
288
301
  def reset_keywords():
289
302
  """Reset all keyword counts"""
aiwaf/trainer.py CHANGED
@@ -51,17 +51,59 @@ def path_exists_in_django(path: str) -> bool:
51
51
 
52
52
 
53
53
  def remove_exempt_keywords() -> None:
54
+ """Remove exempt keywords from dynamic keyword storage"""
54
55
  keyword_store = get_keyword_store()
55
56
  exempt_tokens = set()
56
57
 
58
+ # Extract tokens from exempt paths
57
59
  for path in getattr(settings, "AIWAF_EXEMPT_PATHS", []):
58
60
  for seg in re.split(r"\W+", path.strip("/").lower()):
59
61
  if len(seg) > 3:
60
62
  exempt_tokens.add(seg)
61
63
 
64
+ # Add explicit exempt keywords from settings
65
+ explicit_exempt = getattr(settings, "AIWAF_EXEMPT_KEYWORDS", [])
66
+ exempt_tokens.update(explicit_exempt)
67
+
68
+ # Add legitimate path keywords to prevent them from being learned as suspicious
69
+ allowed_path_keywords = getattr(settings, "AIWAF_ALLOWED_PATH_KEYWORDS", [])
70
+ exempt_tokens.update(allowed_path_keywords)
71
+
62
72
  # Remove exempt tokens from keyword storage
63
73
  for token in exempt_tokens:
64
74
  keyword_store.remove_keyword(token)
75
+
76
+ if exempt_tokens:
77
+ print(f"🧹 Removed {len(exempt_tokens)} exempt keywords from learning: {list(exempt_tokens)[:10]}")
78
+
79
+
80
+ def get_legitimate_keywords() -> set:
81
+ """Get all legitimate keywords that shouldn't be learned as suspicious"""
82
+ legitimate = set()
83
+
84
+ # Common legitimate path segments
85
+ default_legitimate = {
86
+ "profile", "user", "users", "account", "accounts", "settings", "dashboard",
87
+ "home", "about", "contact", "help", "search", "list", "lists",
88
+ "view", "views", "edit", "create", "update", "delete", "detail", "details",
89
+ "api", "auth", "login", "logout", "register", "signup", "signin",
90
+ "reset", "confirm", "activate", "verify", "page", "pages",
91
+ "category", "categories", "tag", "tags", "post", "posts",
92
+ "article", "articles", "blog", "blogs", "news", "item", "items",
93
+ "admin", "administration", "manage", "manager", "control", "panel",
94
+ "config", "configuration", "option", "options", "preference", "preferences"
95
+ }
96
+ legitimate.update(default_legitimate)
97
+
98
+ # Add from Django settings
99
+ allowed_path_keywords = getattr(settings, "AIWAF_ALLOWED_PATH_KEYWORDS", [])
100
+ legitimate.update(allowed_path_keywords)
101
+
102
+ # Add exempt keywords
103
+ exempt_keywords = getattr(settings, "AIWAF_EXEMPT_KEYWORDS", [])
104
+ legitimate.update(exempt_keywords)
105
+
106
+ return legitimate
65
107
 
66
108
 
67
109
  def _read_all_logs() -> list[str]:
@@ -137,14 +179,20 @@ def _parse(line: str) -> dict | None:
137
179
 
138
180
 
139
181
  def train() -> None:
182
+ """Enhanced training with improved keyword filtering and exemption handling"""
183
+ print("🚀 Starting AIWAF enhanced training...")
184
+
185
+ # Remove exempt keywords first
140
186
  remove_exempt_keywords()
141
187
 
142
188
  # Remove any IPs in IPExemption from the blacklist using BlacklistManager
143
189
  exemption_store = get_exemption_store()
144
190
 
145
191
  exempted_ips = [entry['ip_address'] for entry in exemption_store.get_all()]
146
- for ip in exempted_ips:
147
- BlacklistManager.unblock(ip)
192
+ if exempted_ips:
193
+ print(f"🛡️ Found {len(exempted_ips)} exempted IPs - clearing from blacklist")
194
+ for ip in exempted_ips:
195
+ BlacklistManager.unblock(ip)
148
196
 
149
197
  raw_lines = _read_all_logs()
150
198
  if not raw_lines:
@@ -281,17 +329,50 @@ def train() -> None:
281
329
  print(f" → Blocked {blocked_count}/{len(anomalous_ips)} anomalous IPs (others looked legitimate)")
282
330
 
283
331
  tokens = Counter()
332
+ legitimate_keywords = get_legitimate_keywords()
333
+
334
+ print(f"🔍 Learning keywords from {len(parsed)} parsed requests...")
335
+
284
336
  for r in parsed:
285
- if (r["status"].startswith(("4", "5"))
286
- and not path_exists_in_django(r["path"])):
337
+ # Only learn from suspicious requests (errors on non-existent paths)
338
+ if (r["status"].startswith(("4", "5")) and
339
+ not path_exists_in_django(r["path"]) and
340
+ not is_exempt_path(r["path"])):
341
+
287
342
  for seg in re.split(r"\W+", r["path"].lower()):
288
- if len(seg) > 3 and seg not in STATIC_KW:
343
+ if (len(seg) > 3 and
344
+ seg not in STATIC_KW and
345
+ seg not in legitimate_keywords): # Don't learn legitimate keywords
289
346
  tokens[seg] += 1
290
347
 
291
348
  keyword_store = get_keyword_store()
292
- top_tokens = tokens.most_common(10)
349
+ top_tokens = tokens.most_common(getattr(settings, "AIWAF_DYNAMIC_TOP_N", 10))
293
350
 
351
+ # Additional filtering: only add keywords that appear suspicious enough
352
+ filtered_tokens = []
294
353
  for kw, cnt in top_tokens:
295
- keyword_store.add_keyword(kw, cnt)
296
-
297
- print(f"DynamicKeyword storage updated with top tokens: {[kw for kw, _ in top_tokens]}")
354
+ # Don't add keywords that might be legitimate
355
+ if (cnt >= 2 and # Must appear at least twice
356
+ len(kw) >= 4 and # Must be at least 4 characters
357
+ kw not in legitimate_keywords): # Not in legitimate set
358
+ filtered_tokens.append((kw, cnt))
359
+ keyword_store.add_keyword(kw, cnt)
360
+
361
+ if filtered_tokens:
362
+ print(f"📝 Added {len(filtered_tokens)} suspicious keywords: {[kw for kw, _ in filtered_tokens]}")
363
+ else:
364
+ print("✅ No new suspicious keywords learned (good sign!)")
365
+
366
+ print(f"🎯 Dynamic keyword learning complete. Excluded {len(legitimate_keywords)} legitimate keywords.")
367
+
368
+ # Training summary
369
+ print("\n" + "="*60)
370
+ print("🎉 AIWAF ENHANCED TRAINING COMPLETE")
371
+ print("="*60)
372
+ print(f"📊 Training Data: {len(parsed)} log entries processed")
373
+ print(f"🤖 AI Model: Trained with {len(feature_cols)} features")
374
+ print(f"🚫 Blocked IPs: {blocked_count if 'blocked_count' in locals() else 0} suspicious IPs blocked")
375
+ print(f"🔑 Keywords: {len(filtered_tokens)} new suspicious keywords learned")
376
+ print(f"🛡️ Exemptions: {len(exempted_ips)} IPs protected from blocking")
377
+ print(f"✅ Enhanced protection now active with context-aware filtering!")
378
+ print("="*60)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiwaf
3
- Version: 0.1.9.1.9
3
+ Version: 0.1.9.2.0
4
4
  Summary: AI-powered Web Application Firewall
5
5
  Home-page: https://github.com/aayushgauba/aiwaf
6
6
  Author: Aayush Gauba
@@ -25,7 +25,13 @@ Dynamic: requires-python
25
25
  # AI‑WAF
26
26
 
27
27
  > A self‑learning, Django‑friendly Web Application Firewall
28
- > with rate‑limiting, anomaly detection, honeypots, UUID‑tamper protection, dynamic keyword extraction, file‑extension probing detection, exempt path awareness, and daily retraining.
28
+ > with **enhanced context-aware protection**, rate‑limiting, anomaly detection, honeypots, UUID‑tamper protection, **smart keyword learning**, file‑extension probing detection, exempt path awareness, and daily retraining.
29
+
30
+ **🆕 Latest Enhancements:**
31
+ - ✅ **Smart Keyword Filtering** - Prevents blocking legitimate pages like `/profile/`
32
+ - ✅ **Granular Reset Commands** - Clear specific data types (`--blacklist`, `--keywords`, `--exemptions`)
33
+ - ✅ **Context-Aware Learning** - Only learns from suspicious requests, not legitimate site functionality
34
+ - ✅ **Enhanced Configuration** - `AIWAF_ALLOWED_PATH_KEYWORDS` and `AIWAF_EXEMPT_KEYWORDS`
29
35
 
30
36
  ---
31
37
 
@@ -88,9 +94,14 @@ aiwaf/
88
94
  - Burst count
89
95
  - Total 404s
90
96
 
91
- - **Dynamic Keyword Extraction & Cleanup**
92
- - Every retrain adds top 10 keyword segments from 4xx/5xx paths
93
- - **If a path is added to `AIWAF_EXEMPT_PATHS`, its keywords are automatically removed from the database**
97
+ - **Enhanced Dynamic Keyword Learning**
98
+ - **Smart Context-Aware Learning**: Only learns keywords from suspicious requests on non-existent paths
99
+ - **Legitimate Path Protection**: Automatically excludes keywords from valid Django URLs (like `/profile/`, `/admin/`)
100
+ - **Configuration Options**:
101
+ - `AIWAF_ALLOWED_PATH_KEYWORDS` - Explicitly allow certain keywords in legitimate paths
102
+ - `AIWAF_EXEMPT_KEYWORDS` - Keywords that should never trigger blocking
103
+ - **Automatic Cleanup**: Keywords from `AIWAF_EXEMPT_PATHS` are automatically removed from the database
104
+ - **False Positive Prevention**: Stops learning legitimate site functionality as "malicious"
94
105
 
95
106
  - **File‑Extension Probing Detection**
96
107
  Tracks repeated 404s on common extensions (e.g. `.php`, `.asp`) and blocks IPs.
@@ -196,20 +207,44 @@ python manage.py add_ipexemption <ip-address> --reason "optional reason"
196
207
 
197
208
  ### Resetting AI-WAF
198
209
 
199
- Clear all blacklist and exemption entries:
210
+ The `aiwaf_reset` command provides **granular control** for clearing different types of data:
200
211
 
201
212
  ```bash
202
- # Clear everything (with confirmation prompt)
213
+ # Clear everything (default - backward compatible)
203
214
  python manage.py aiwaf_reset
204
215
 
205
- # Clear everything without confirmation
216
+ # Clear everything without confirmation prompt
206
217
  python manage.py aiwaf_reset --confirm
207
218
 
208
- # Clear only blacklist entries
209
- python manage.py aiwaf_reset --blacklist-only
219
+ # 🆕 GRANULAR CONTROL - Clear specific data types
220
+ python manage.py aiwaf_reset --blacklist # Clear only blocked IPs
221
+ python manage.py aiwaf_reset --exemptions # Clear only exempted IPs
222
+ python manage.py aiwaf_reset --keywords # Clear only learned keywords
223
+
224
+ # 🔧 COMBINE OPTIONS - Mix and match as needed
225
+ python manage.py aiwaf_reset --blacklist --keywords # Keep exemptions
226
+ python manage.py aiwaf_reset --exemptions --keywords # Keep blacklist
227
+ python manage.py aiwaf_reset --blacklist --exemptions # Keep keywords
228
+
229
+ # 🚀 COMMON USE CASES
230
+ # Fix false positive keywords (like "profile" blocking legitimate pages)
231
+ python manage.py aiwaf_reset --keywords --confirm
232
+ python manage.py detect_and_train # Retrain with enhanced filtering
233
+
234
+ # Clear blocked IPs but preserve exemptions and learning
235
+ python manage.py aiwaf_reset --blacklist --confirm
236
+
237
+ # Legacy support (still works for backward compatibility)
238
+ python manage.py aiwaf_reset --blacklist-only # Legacy: blacklist only
239
+ python manage.py aiwaf_reset --exemptions-only # Legacy: exemptions only
240
+ ```
210
241
 
211
- # Clear only exemption entries
212
- python manage.py aiwaf_reset --exemptions-only
242
+ **Enhanced Feedback:**
243
+ ```bash
244
+ $ python manage.py aiwaf_reset --keywords
245
+ 🔧 AI-WAF Reset: Clear 15 learned keywords
246
+ Are you sure you want to proceed? [y/N]: y
247
+ ✅ Reset complete: Deleted 15 learned keywords
213
248
  ```
214
249
 
215
250
  ### Checking Dependencies
@@ -482,6 +517,21 @@ AIWAF_EXEMPT_PATHS = [ # optional but highly recommended
482
517
  "/media/",
483
518
  "/health/",
484
519
  ]
520
+
521
+ # 🆕 ENHANCED KEYWORD FILTERING OPTIONS
522
+ AIWAF_ALLOWED_PATH_KEYWORDS = [ # Keywords allowed in legitimate paths
523
+ "profile", "user", "account", "settings", "dashboard",
524
+ "admin", "api", "auth", "search", "contact", "about",
525
+ # Add your site-specific legitimate keywords
526
+ "buddycraft", "sc2", "starcraft", # Example: gaming site keywords
527
+ ]
528
+
529
+ AIWAF_EXEMPT_KEYWORDS = [ # Keywords that never trigger blocking
530
+ "api", "webhook", "health", "static", "media",
531
+ "upload", "download", "backup", "profile"
532
+ ]
533
+
534
+ AIWAF_DYNAMIC_TOP_N = 10 # Number of dynamic keywords to learn (default: 10)
485
535
  ```
486
536
 
487
537
  > **Note:** You no longer need to define `AIWAF_MALICIOUS_KEYWORDS` or `AIWAF_STATUS_CODES` — they evolve dynamically.
@@ -680,6 +730,65 @@ python manage.py detect_and_train
680
730
 
681
731
  ---
682
732
 
733
+ ## 🔧 Troubleshooting
734
+
735
+ ### Legitimate Pages Being Blocked
736
+
737
+ **Problem**: Users can't access legitimate pages like `/en/profile/` due to keyword blocking.
738
+
739
+ **Cause**: AIWAF learned legitimate keywords (like "profile") as suspicious from previous traffic.
740
+
741
+ **Solution**:
742
+ ```bash
743
+ # 1. Clear problematic learned keywords
744
+ python manage.py aiwaf_reset --keywords --confirm
745
+
746
+ # 2. Add legitimate keywords to settings
747
+ # In settings.py:
748
+ AIWAF_ALLOWED_PATH_KEYWORDS = [
749
+ "profile", "user", "account", "dashboard",
750
+ # Add your site-specific keywords
751
+ ]
752
+
753
+ # 3. Retrain with enhanced filtering (won't learn legitimate keywords)
754
+ python manage.py detect_and_train
755
+
756
+ # 4. Test - legitimate pages should now work!
757
+ ```
758
+
759
+ ### Preventing Future False Positives
760
+
761
+ Configure AIWAF to recognize your site's legitimate keywords:
762
+
763
+ ```python
764
+ # settings.py
765
+ AIWAF_ALLOWED_PATH_KEYWORDS = [
766
+ # Common legitimate keywords
767
+ "profile", "user", "account", "settings", "dashboard",
768
+ "admin", "search", "contact", "about", "help",
769
+
770
+ # Your site-specific keywords
771
+ "buddycraft", "sc2", "starcraft", # Gaming site example
772
+ "shop", "cart", "checkout", # E-commerce example
773
+ "blog", "article", "news", # Content site example
774
+ ]
775
+ ```
776
+
777
+ ### Reset Command Options
778
+
779
+ ```bash
780
+ # Clear everything (safest for troubleshooting)
781
+ python manage.py aiwaf_reset --confirm
782
+
783
+ # Clear only problematic keywords
784
+ python manage.py aiwaf_reset --keywords --confirm
785
+
786
+ # Clear blocked IPs but keep exemptions
787
+ python manage.py aiwaf_reset --blacklist --confirm
788
+ ```
789
+
790
+ ---
791
+
683
792
  ## 🧠 How It Works
684
793
 
685
794
  | Middleware | Purpose |
@@ -1,12 +1,12 @@
1
- aiwaf/__init__.py,sha256=BGGn_OwueGmxbbWRV-PwE7HGpzB5Ol61jhYI6z4tHug,220
1
+ aiwaf/__init__.py,sha256=Bn2DcnLiYvx-vkOUfIbQtnKUDidKK1rAUNuXuPa36MM,220
2
2
  aiwaf/apps.py,sha256=nCez-Ptlv2kaEk5HenA8b1pATz1VfhrHP1344gwcY1A,142
3
3
  aiwaf/blacklist_manager.py,sha256=LYCeKFB-7e_C6Bg2WeFJWFIIQlrfRMPuGp30ivrnhQY,1196
4
4
  aiwaf/decorators.py,sha256=IUKOdM_gdroffImRZep1g1wT6gNqD10zGwcp28hsJCs,825
5
- aiwaf/middleware.py,sha256=EMAQA_Gnz0jv4nevlognT921ZeBEro13J_DSv_mQ3Dw,15482
5
+ aiwaf/middleware.py,sha256=D1HavBGJbpPneOtkkCVFddlOQwCdoWcugmHOvn5THDU,22614
6
6
  aiwaf/middleware_logger.py,sha256=LWZVDAnjh6CGESirA8eMbhGgJKB7lVDGRQqVroH95Lo,4742
7
7
  aiwaf/models.py,sha256=vQxgY19BDVMjoO903UNrTZC1pNoLltMU6wbyWPoAEns,2719
8
- aiwaf/storage.py,sha256=vswojWT8KEH5h24TQ9wwYCsxRUOjaAKudtFJnFxNHKk,9914
9
- aiwaf/trainer.py,sha256=1RPjWVOdGQ3qSrjFopw8HKu7THVTMvF4nNYouij6i_A,10685
8
+ aiwaf/storage.py,sha256=5ImrZMRn3u7HNsPH0fDjWhDrD2tgG2IHVnOXtLz0fk4,10253
9
+ aiwaf/trainer.py,sha256=UHkfrbJI47bGJPCz0Vws6r23WvGpemMHf5ScHWG_I1I,14568
10
10
  aiwaf/utils.py,sha256=BJk5vJCYdGPl_4QQiknjhCbkzv5HZCXgFcBJDMJpHok,3390
11
11
  aiwaf/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
12
  aiwaf/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -14,7 +14,7 @@ aiwaf/management/commands/add_exemption.py,sha256=U_ByfJw1EstAZ8DaSoRb97IGwYzXs0
14
14
  aiwaf/management/commands/add_ipexemption.py,sha256=sSf3d9hGK9RqqlBYkCrnrd8KZWGT-derSpoWnEY4H60,952
15
15
  aiwaf/management/commands/aiwaf_diagnose.py,sha256=nXFRhq66N4QC3e4scYJ2sUngJce-0yDxtBO3R2BllRM,6134
16
16
  aiwaf/management/commands/aiwaf_logging.py,sha256=FCIqULn2tii2vD9VxL7vk3PV4k4vr7kaA00KyaCExYY,7692
17
- aiwaf/management/commands/aiwaf_reset.py,sha256=wG7EcdPqkxmjF2ivQOmZ7swuvHVJ_OVLgOEijGLvmFs,5586
17
+ aiwaf/management/commands/aiwaf_reset.py,sha256=pcF0zOYDSqjpCwDtk2HYJZLgr76td8OFRENtl20c1dQ,7472
18
18
  aiwaf/management/commands/check_dependencies.py,sha256=GOZl00pDwW2cJjDvIaCeB3yWxmeYcJDRTIpmOTLvy2c,37204
19
19
  aiwaf/management/commands/clear_blacklist.py,sha256=Tisedg0EVlc3E01mA3hBZQorwMzc5j1cns-oYshja0g,2770
20
20
  aiwaf/management/commands/clear_cache.py,sha256=cdnuTgxkhKLqT_6k6yTcEBlREovNRQxAE51ceXlGYMA,647
@@ -28,8 +28,8 @@ aiwaf/management/commands/test_exemption_fix.py,sha256=ngyGaHUCmQQ6y--6j4q1viZJt
28
28
  aiwaf/resources/model.pkl,sha256=5t6h9BX8yoh2xct85MXOO60jdlWyg1APskUOW0jZE1Y,1288265
29
29
  aiwaf/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
30
30
  aiwaf/templatetags/aiwaf_tags.py,sha256=XXfb7Tl4DjU3Sc40GbqdaqOEtKTUKELBEk58u83wBNw,357
31
- aiwaf-0.1.9.1.9.dist-info/licenses/LICENSE,sha256=Ir8PX4dxgAcdB0wqNPIkw84fzIIRKE75NoUil9RX0QU,1069
32
- aiwaf-0.1.9.1.9.dist-info/METADATA,sha256=YeyuawG8pPFTBrOOBp8MayiGxCdyywAFvKKMY8dIk-M,22145
33
- aiwaf-0.1.9.1.9.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
34
- aiwaf-0.1.9.1.9.dist-info/top_level.txt,sha256=kU6EyjobT6UPCxuWpI_BvcHDG0I2tMgKaPlWzVxe2xI,6
35
- aiwaf-0.1.9.1.9.dist-info/RECORD,,
31
+ aiwaf-0.1.9.2.0.dist-info/licenses/LICENSE,sha256=Ir8PX4dxgAcdB0wqNPIkw84fzIIRKE75NoUil9RX0QU,1069
32
+ aiwaf-0.1.9.2.0.dist-info/METADATA,sha256=HM_8Dh89XWhMKtLDkqrvA7fxzR97F9Ph1sY2bYOk9Mc,26414
33
+ aiwaf-0.1.9.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
34
+ aiwaf-0.1.9.2.0.dist-info/top_level.txt,sha256=kU6EyjobT6UPCxuWpI_BvcHDG0I2tMgKaPlWzVxe2xI,6
35
+ aiwaf-0.1.9.2.0.dist-info/RECORD,,