aiwaf 0.1.7.6__py3-none-any.whl → 0.1.7.8__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.

@@ -0,0 +1,22 @@
1
+ from django.core.management.base import BaseCommand, CommandError
2
+ from aiwaf.models import IPExemption
3
+
4
+ class Command(BaseCommand):
5
+ help = 'Add an IP address to the IPExemption list (prevents blacklisting)'
6
+
7
+ def add_arguments(self, parser):
8
+ parser.add_argument('ip', type=str, help='IP address to exempt')
9
+ parser.add_argument('--reason', type=str, default='', help='Reason for exemption (optional)')
10
+
11
+ def handle(self, *args, **options):
12
+ ip = options['ip']
13
+ reason = options['reason']
14
+ obj, created = IPExemption.objects.get_or_create(ip_address=ip, defaults={'reason': reason})
15
+ if not created:
16
+ self.stdout.write(self.style.WARNING(f'IP {ip} is already exempted.'))
17
+ else:
18
+ self.stdout.write(self.style.SUCCESS(f'IP {ip} added to exemption list.'))
19
+ if reason:
20
+ obj.reason = reason
21
+ obj.save()
22
+ self.stdout.write(self.style.SUCCESS(f'Reason set to: {reason}'))
aiwaf/middleware.py CHANGED
@@ -16,7 +16,9 @@ from django.apps import apps
16
16
  from django.urls import get_resolver
17
17
  from .trainer import STATIC_KW, STATUS_IDX, is_exempt_path, path_exists_in_django
18
18
  from .blacklist_manager import BlacklistManager
19
- from .models import DynamicKeyword
19
+ from .models import DynamicKeyword, IPExemption
20
+ def is_ip_exempted(ip):
21
+ return IPExemption.objects.filter(ip_address=ip).exists()
20
22
 
21
23
  def is_exempt_path(path):
22
24
  path = path.lower()
@@ -77,6 +79,8 @@ class IPAndKeywordBlockMiddleware:
77
79
  return self.get_response(request)
78
80
  ip = get_ip(request)
79
81
  path = raw_path.lstrip("/")
82
+ if is_ip_exempted(ip):
83
+ return self.get_response(request)
80
84
  if BlacklistManager.is_blocked(ip):
81
85
  return JsonResponse({"error": "blocked"}, status=403)
82
86
  segments = [seg for seg in re.split(r"\W+", path) if len(seg) > 3]
@@ -95,8 +99,9 @@ class IPAndKeywordBlockMiddleware:
95
99
  }
96
100
  for seg in segments:
97
101
  if seg in suspicious_kw:
98
- BlacklistManager.block(ip, f"Keyword block: {seg}")
99
- return JsonResponse({"error": "blocked"}, status=403)
102
+ if not is_ip_exempted(ip):
103
+ BlacklistManager.block(ip, f"Keyword block: {seg}")
104
+ return JsonResponse({"error": "blocked"}, status=403)
100
105
  return self.get_response(request)
101
106
 
102
107
 
@@ -120,8 +125,9 @@ class RateLimitMiddleware:
120
125
  timestamps.append(now)
121
126
  cache.set(key, timestamps, timeout=self.WINDOW)
122
127
  if len(timestamps) > self.FLOOD:
123
- BlacklistManager.block(ip, "Flood pattern")
124
- return JsonResponse({"error": "blocked"}, status=403)
128
+ if not is_ip_exempted(ip):
129
+ BlacklistManager.block(ip, "Flood pattern")
130
+ return JsonResponse({"error": "blocked"}, status=403)
125
131
  if len(timestamps) > self.MAX:
126
132
  return JsonResponse({"error": "too_many_requests"}, status=429)
127
133
  return self.get_response(request)
@@ -141,6 +147,8 @@ class AIAnomalyMiddleware(MiddlewareMixin):
141
147
  return None
142
148
  request._start_time = time.time()
143
149
  ip = get_ip(request)
150
+ if is_ip_exempted(ip):
151
+ return None
144
152
  if BlacklistManager.is_blocked(ip):
145
153
  return JsonResponse({"error": "blocked"}, status=403)
146
154
  return None
@@ -166,8 +174,9 @@ class AIAnomalyMiddleware(MiddlewareMixin):
166
174
  feats = [path_len, kw_hits, resp_time, status_idx, burst_count, total_404]
167
175
  X = np.array(feats, dtype=float).reshape(1, -1)
168
176
  if self.model.predict(X)[0] == -1:
169
- BlacklistManager.block(ip, "AI anomaly")
170
- return JsonResponse({"error": "blocked"}, status=403)
177
+ if not is_ip_exempted(ip):
178
+ BlacklistManager.block(ip, "AI anomaly")
179
+ return JsonResponse({"error": "blocked"}, status=403)
171
180
 
172
181
  data.append((now, request.path, response.status_code, resp_time))
173
182
  data = [d for d in data if now - d[0] < self.WINDOW]
@@ -187,8 +196,9 @@ class HoneypotMiddleware(MiddlewareMixin):
187
196
  trap = request.POST.get(getattr(settings, "AIWAF_HONEYPOT_FIELD", "hp_field"), "")
188
197
  if trap:
189
198
  ip = get_ip(request)
190
- BlacklistManager.block(ip, "HONEYPOT triggered")
191
- return JsonResponse({"error": "bot_detected"}, status=403)
199
+ if not is_ip_exempted(ip):
200
+ BlacklistManager.block(ip, "HONEYPOT triggered")
201
+ return JsonResponse({"error": "bot_detected"}, status=403)
192
202
  return None
193
203
 
194
204
 
@@ -211,5 +221,6 @@ class UUIDTamperMiddleware(MiddlewareMixin):
211
221
  except (ValueError, TypeError):
212
222
  continue
213
223
 
214
- BlacklistManager.block(ip, "UUID tampering")
215
- return JsonResponse({"error": "blocked"}, status=403)
224
+ if not is_ip_exempted(ip):
225
+ BlacklistManager.block(ip, "UUID tampering")
226
+ return JsonResponse({"error": "blocked"}, status=403)
aiwaf/models.py CHANGED
@@ -33,4 +33,14 @@ class DynamicKeyword(models.Model):
33
33
  last_updated = models.DateTimeField(auto_now=True)
34
34
 
35
35
  class Meta:
36
- ordering = ['-count']
36
+ ordering = ['-count']
37
+
38
+
39
+ # Model to store IP addresses that are exempt from blacklisting
40
+ class IPExemption(models.Model):
41
+ ip_address = models.GenericIPAddressField(unique=True, db_index=True)
42
+ reason = models.CharField(max_length=100, blank=True, default="")
43
+ created_at = models.DateTimeField(auto_now_add=True)
44
+
45
+ def __str__(self):
46
+ return f"{self.ip_address} (Exempted: {self.reason})"
aiwaf/resources/model.pkl CHANGED
Binary file
aiwaf/trainer.py CHANGED
@@ -2,23 +2,23 @@ import os
2
2
  import glob
3
3
  import gzip
4
4
  import re
5
+ import joblib
6
+
5
7
  from datetime import datetime
6
8
  from collections import defaultdict, Counter
7
9
 
8
10
  import pandas as pd
9
11
  from sklearn.ensemble import IsolationForest
10
- import joblib
11
12
 
12
13
  from django.conf import settings
13
14
  from django.apps import apps
14
15
  from django.db.models import F
15
- from django.urls import get_resolver
16
16
 
17
-
18
- LOG_PATH = settings.AIWAF_ACCESS_LOG
17
+ # ─────────── Configuration ───────────
18
+ LOG_PATH = settings.AIWAF_ACCESS_LOG
19
19
  MODEL_PATH = os.path.join(os.path.dirname(__file__), "resources", "model.pkl")
20
20
 
21
- STATIC_KW = [".php", "xmlrpc", "wp-", ".env", ".git", ".bak", "conflg", "shell", "filemanager"]
21
+ STATIC_KW = [".php", "xmlrpc", "wp-", ".env", ".git", ".bak", "conflg", "shell", "filemanager"]
22
22
  STATUS_IDX = ["200", "403", "404", "500"]
23
23
 
24
24
  _LOG_RX = re.compile(
@@ -26,112 +26,114 @@ _LOG_RX = re.compile(
26
26
  r'(\d{3}).*?"(.*?)" "(.*?)".*?response-time=(\d+\.\d+)'
27
27
  )
28
28
 
29
+
29
30
  BlacklistEntry = apps.get_model("aiwaf", "BlacklistEntry")
30
31
  DynamicKeyword = apps.get_model("aiwaf", "DynamicKeyword")
32
+ IPExemption = apps.get_model("aiwaf", "IPExemption")
33
+
31
34
 
32
- def is_exempt_path(path):
35
+ def is_exempt_path(path: str) -> bool:
33
36
  path = path.lower()
34
- exempt_paths = getattr(settings, "AIWAF_EXEMPT_PATHS", [])
35
- for exempt in exempt_paths:
37
+ for exempt in getattr(settings, "AIWAF_EXEMPT_PATHS", []):
36
38
  if path == exempt or path.startswith(exempt.rstrip("/") + "/"):
37
39
  return True
38
40
  return False
39
41
 
40
- def path_exists_in_django(path):
42
+
43
+ def path_exists_in_django(path: str) -> bool:
41
44
  from django.urls import get_resolver
42
- from django.urls.resolvers import URLPattern, URLResolver
45
+ from django.urls.resolvers import URLResolver
43
46
 
44
- path = path.split("?")[0].lstrip("/")
47
+ candidate = path.split("?")[0].lstrip("/")
45
48
  try:
46
- get_resolver().resolve(f"/{path}")
49
+ get_resolver().resolve(f"/{candidate}")
47
50
  return True
48
51
  except:
49
52
  pass
50
- parts = path.split("/")
51
- root_resolver = get_resolver()
52
- for pattern in root_resolver.url_patterns:
53
- if isinstance(pattern, URLResolver):
54
- prefix = pattern.pattern.describe().strip("^/")
55
- if prefix and path.startswith(prefix):
53
+
54
+ root = get_resolver()
55
+ for p in root.url_patterns:
56
+ if isinstance(p, URLResolver):
57
+ prefix = p.pattern.describe().strip("^/")
58
+ if prefix and candidate.startswith(prefix):
56
59
  return True
57
60
  return False
58
61
 
59
- def remove_exempt_keywords():
60
- exempt_paths = getattr(settings, "AIWAF_EXEMPT_PATHS", [])
61
- exempt_tokens = set()
62
-
63
- for path in exempt_paths:
64
- path = path.strip("/").lower()
65
- segments = re.split(r"\W+", path)
66
- exempt_tokens.update(seg for seg in segments if len(seg) > 3)
67
62
 
63
+ def remove_exempt_keywords() -> None:
64
+ exempt_tokens = set()
65
+ for path in getattr(settings, "AIWAF_EXEMPT_PATHS", []):
66
+ for seg in re.split(r"\W+", path.strip("/").lower()):
67
+ if len(seg) > 3:
68
+ exempt_tokens.add(seg)
68
69
  if exempt_tokens:
69
- deleted_count, _ = DynamicKeyword.objects.filter(keyword__in=exempt_tokens).delete()
70
- print(f"Removed {deleted_count} dynamic keywords that are now exempt: {list(exempt_tokens)}")
70
+ DynamicKeyword.objects.filter(keyword__in=exempt_tokens).delete()
71
+
71
72
 
72
- def _read_all_logs():
73
+ def _read_all_logs() -> list[str]:
73
74
  lines = []
74
75
  if LOG_PATH and os.path.exists(LOG_PATH):
75
76
  with open(LOG_PATH, "r", errors="ignore") as f:
76
77
  lines.extend(f.readlines())
77
- for path in sorted(glob.glob(f"{LOG_PATH}.*")):
78
- opener = gzip.open if path.endswith(".gz") else open
78
+ for p in sorted(glob.glob(f"{LOG_PATH}.*")):
79
+ opener = gzip.open if p.endswith(".gz") else open
79
80
  try:
80
- with opener(path, "rt", errors="ignore") as f:
81
+ with opener(p, "rt", errors="ignore") as f:
81
82
  lines.extend(f.readlines())
82
83
  except OSError:
83
84
  continue
84
85
  return lines
85
86
 
86
87
 
87
- def _parse(line):
88
+ def _parse(line: str) -> dict | None:
88
89
  m = _LOG_RX.search(line)
89
90
  if not m:
90
91
  return None
91
- ip, ts_str, path, status, ref, ua, rt = m.groups()
92
+ ip, ts_str, path, status, *_ , rt = m.groups()
92
93
  try:
93
94
  ts = datetime.strptime(ts_str.split()[0], "%d/%b/%Y:%H:%M:%S")
94
95
  except ValueError:
95
96
  return None
96
97
  return {
97
- "ip": ip,
98
- "timestamp": ts,
99
- "path": path,
100
- "status": status,
101
- "ua": ua,
98
+ "ip": ip,
99
+ "timestamp": ts,
100
+ "path": path,
101
+ "status": status,
102
102
  "response_time": float(rt),
103
103
  }
104
104
 
105
105
 
106
-
107
- def train():
106
+ def train() -> None:
108
107
  remove_exempt_keywords()
108
+ # Remove any IPs in IPExemption from the blacklist
109
+ exempt_ips = set(IPExemption.objects.values_list("ip_address", flat=True))
110
+ if exempt_ips:
111
+ BlacklistEntry.objects.filter(ip_address__in=exempt_ips).delete()
109
112
  raw_lines = _read_all_logs()
110
113
  if not raw_lines:
111
114
  print("No log lines found – check AIWAF_ACCESS_LOG setting.")
112
115
  return
116
+
113
117
  parsed = []
114
- ip_404 = defaultdict(int)
118
+ ip_404 = defaultdict(int)
115
119
  ip_times = defaultdict(list)
116
- for ln in raw_lines:
117
- rec = _parse(ln)
120
+
121
+ for line in raw_lines:
122
+ rec = _parse(line)
118
123
  if not rec:
119
124
  continue
120
125
  parsed.append(rec)
121
126
  ip_times[rec["ip"]].append(rec["timestamp"])
122
127
  if rec["status"] == "404":
123
128
  ip_404[rec["ip"]] += 1
124
- blocked_404 = []
129
+
130
+ # 3. Optional immediate 404‐flood blocking
125
131
  for ip, count in ip_404.items():
126
132
  if count >= 6:
127
- obj, created = BlacklistEntry.objects.get_or_create(
133
+ BlacklistEntry.objects.get_or_create(
128
134
  ip_address=ip,
129
135
  defaults={"reason": "Excessive 404s (≥6)"}
130
136
  )
131
- if created:
132
- blocked_404.append(ip)
133
- if blocked_404:
134
- print(f"Blocked {len(blocked_404)} IPs for 404 flood: {blocked_404}")
135
137
 
136
138
  feature_dicts = []
137
139
  for r in parsed:
@@ -140,60 +142,55 @@ def train():
140
142
  1 for t in ip_times[ip]
141
143
  if (r["timestamp"] - t).total_seconds() <= 10
142
144
  )
143
- total404 = ip_404[ip]
144
- is_known_path = path_exists_in_django(r["path"])
145
- kw_hits = 0
146
- if not is_known_path and not is_exempt_path(r["path"]):
145
+ total404 = ip_404[ip]
146
+ known_path = path_exists_in_django(r["path"])
147
+ kw_hits = 0
148
+ if not known_path and not is_exempt_path(r["path"]):
147
149
  kw_hits = sum(k in r["path"].lower() for k in STATIC_KW)
150
+
148
151
  status_idx = STATUS_IDX.index(r["status"]) if r["status"] in STATUS_IDX else -1
152
+
149
153
  feature_dicts.append({
150
- "ip": ip,
151
- "path_len": len(r["path"]),
152
- "kw_hits": kw_hits,
153
- "resp_time": r["response_time"],
154
- "status_idx": status_idx,
155
- "burst_count": burst,
156
- "total_404": total404,
154
+ "ip": ip,
155
+ "path_len": len(r["path"]),
156
+ "kw_hits": kw_hits,
157
+ "resp_time": r["response_time"],
158
+ "status_idx": status_idx,
159
+ "burst_count": burst,
160
+ "total_404": total404,
157
161
  })
158
162
 
159
163
  if not feature_dicts:
160
164
  print("⚠️ Nothing to train on – no valid log entries.")
161
165
  return
166
+
162
167
  df = pd.DataFrame(feature_dicts)
163
168
  feature_cols = [c for c in df.columns if c != "ip"]
164
169
  X = df[feature_cols].astype(float).values
165
170
  model = IsolationForest(contamination=0.01, random_state=42)
166
171
  model.fit(X)
172
+
167
173
  os.makedirs(os.path.dirname(MODEL_PATH), exist_ok=True)
168
174
  joblib.dump(model, MODEL_PATH)
169
175
  print(f"Model trained on {len(X)} samples → {MODEL_PATH}")
170
176
  preds = model.predict(X)
171
- anomalous_ips = set(df.loc[preds == -1, 'ip'])
172
- blocked_anom = []
177
+ anomalous_ips = set(df.loc[preds == -1, "ip"])
173
178
  for ip in anomalous_ips:
174
- obj, created = BlacklistEntry.objects.get_or_create(
179
+ BlacklistEntry.objects.get_or_create(
175
180
  ip_address=ip,
176
181
  defaults={"reason": "Anomalous behavior"}
177
182
  )
178
- if created:
179
- blocked_anom.append(ip)
180
- if blocked_anom:
181
- print(f"🚫 Blocked {len(blocked_anom)} anomalous IPs: {blocked_anom}")
183
+
182
184
  tokens = Counter()
183
185
  for r in parsed:
184
- if r["status"].startswith(("4", "5")) and not path_exists_in_django(r["path"]):
186
+ if (r["status"].startswith(("4", "5"))
187
+ and not path_exists_in_django(r["path"])):
185
188
  for seg in re.split(r"\W+", r["path"].lower()):
186
189
  if len(seg) > 3 and seg not in STATIC_KW:
187
190
  tokens[seg] += 1
188
191
 
189
- top_tokens = tokens.most_common(10)
190
- for kw, cnt in top_tokens:
192
+ for kw, cnt in tokens.most_common(10):
191
193
  obj, _ = DynamicKeyword.objects.get_or_create(keyword=kw)
192
194
  DynamicKeyword.objects.filter(pk=obj.pk).update(count=F("count") + cnt)
193
195
 
194
- print(f" DynamicKeyword DB updated with top tokens: {[kw for kw, _ in top_tokens]}")
195
-
196
-
197
-
198
- if __name__ == "__main__":
199
- train()
196
+ print(f"DynamicKeyword DB updated with top tokens: {[kw for kw, _ in tokens.most_common(10)]}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiwaf
3
- Version: 0.1.7.6
3
+ Version: 0.1.7.8
4
4
  Summary: AI-powered Web Application Firewall
5
5
  Home-page: https://github.com/aayushgauba/aiwaf
6
6
  Author: Aayush Gauba
@@ -9,6 +9,11 @@ License: MIT
9
9
  Requires-Python: >=3.8
10
10
  Description-Content-Type: text/markdown
11
11
  License-File: LICENSE
12
+ Requires-Dist: Django>=3.2
13
+ Requires-Dist: numpy>=1.21
14
+ Requires-Dist: pandas>=1.3
15
+ Requires-Dist: scikit-learn>=1.0
16
+ Requires-Dist: joblib>=1.1
12
17
  Dynamic: author
13
18
  Dynamic: home-page
14
19
  Dynamic: license-file
@@ -78,13 +83,32 @@ aiwaf/
78
83
  - **UUID Tampering Protection**
79
84
  Blocks guessed or invalid UUIDs that don’t resolve to real models.
80
85
 
81
- - **Exempt Path Awareness**
82
- Fully respects `AIWAF_EXEMPT_PATHS` across all modules — exempt paths are:
86
+
87
+ **Exempt Path & IP Awareness**
88
+
89
+ **Exempt Paths:**
90
+ Set `AIWAF_EXEMPT_PATHS` in your Django `settings.py` (not in your code). Fully respects this setting across all modules — exempt paths are:
83
91
  - Skipped from keyword learning
84
92
  - Immune to AI blocking
85
93
  - Ignored in log training
86
94
  - Cleaned from `DynamicKeyword` model automatically
87
95
 
96
+ **Exempt IPs:**
97
+ You can exempt specific IP addresses from all blocking and blacklisting logic. Exempted IPs will:
98
+ - Never be added to the blacklist (even if they trigger rules)
99
+ - Be automatically removed from the blacklist during retraining
100
+ - Bypass all block/deny logic in middleware
101
+
102
+ ### Managing Exempt IPs
103
+
104
+ Add an IP to the exemption list using the management command:
105
+
106
+ ```bash
107
+ python manage.py add_ipexemption <ip-address> --reason "optional reason"
108
+ ```
109
+
110
+ This will ensure the IP is never blocked by AI‑WAF. You can also manage exemptions via the Django admin interface.
111
+
88
112
  - **Daily Retraining**
89
113
  Reads rotated logs, auto‑blocks 404 floods, retrains the IsolationForest, updates `model.pkl`, and evolves the keyword DB.
90
114
 
@@ -125,7 +149,7 @@ AIWAF_RATE_MAX = 20 # max requests per window
125
149
  AIWAF_RATE_FLOOD = 10 # flood threshold
126
150
  AIWAF_WINDOW_SECONDS = 60 # anomaly detection window
127
151
  AIWAF_FILE_EXTENSIONS = [".php", ".asp", ".jsp"]
128
- AIWAF_EXEMPT_PATHS = [ # optional but highly recommended
152
+ AIWAF_EXEMPT_PATHS = [ # optional but highly recommended
129
153
  "/favicon.ico",
130
154
  "/robots.txt",
131
155
  "/static/",
@@ -1,19 +1,20 @@
1
1
  aiwaf/__init__.py,sha256=nQFpJ1YpX48snzLjEQCf8zD2YNh8v0b_kPTrXx8uBYc,46
2
2
  aiwaf/apps.py,sha256=nCez-Ptlv2kaEk5HenA8b1pATz1VfhrHP1344gwcY1A,142
3
3
  aiwaf/blacklist_manager.py,sha256=sM6uTH7zD6MOPGb0kzqV2aFut2vxKgft_UVeRJr7klw,392
4
- aiwaf/middleware.py,sha256=kH77E1xWVIjQF6frUGM6kdoz-gZXGAh43Fj-2hPEbSM,7990
5
- aiwaf/models.py,sha256=8au1umopgCo0lthztTTRrYRJQUM7uX8eAeXgs3z45K4,1282
4
+ aiwaf/middleware.py,sha256=8iMoJXGQ86HtvVbmAJa7ykZG-QRdXMIfYmD-hbF8TFg,8456
5
+ aiwaf/models.py,sha256=XaG1pd_oZu3y-fw66u4wblGlWcUY9gvsTNKGD0kQk7Y,1672
6
6
  aiwaf/storage.py,sha256=bxCILzzvA1-q6nwclRE8WrfoRhe25H4VrsQDf0hl_lY,1903
7
- aiwaf/trainer.py,sha256=ir5kFTeLQuhMd2h094ct03Wr-rNZsX-mZHwjLx29F54,6422
7
+ aiwaf/trainer.py,sha256=Xs_AuA7RCa1oyo5-lJYlnRYUiaq-HY2KXAviAdiGnzU,6217
8
8
  aiwaf/utils.py,sha256=RkEUWhhHy6tOk7V0UYv3cN4xhOR_7aBy9bjhwuV2cdA,1436
9
9
  aiwaf/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
10
  aiwaf/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ aiwaf/management/commands/add_ipexemption.py,sha256=LWN21_ydqSjU3_hUnkou4Ciyrk_479zLvcKdWm8hkC0,988
11
12
  aiwaf/management/commands/detect_and_train.py,sha256=-o-LZ7QZ5GeJPCekryox1DGXKMmFEkwwrcDsiM166K0,269
12
- aiwaf/resources/model.pkl,sha256=rCCXH38SJrnaOba2WZrU1LQVzWT34x6bTVkq20XJU-Q,1091129
13
+ aiwaf/resources/model.pkl,sha256=5t6h9BX8yoh2xct85MXOO60jdlWyg1APskUOW0jZE1Y,1288265
13
14
  aiwaf/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
15
  aiwaf/templatetags/aiwaf_tags.py,sha256=1KGqeioYmgKACDUiPkykSqI7DLQ6-Ypy1k00weWj9iY,399
15
- aiwaf-0.1.7.6.dist-info/licenses/LICENSE,sha256=Ir8PX4dxgAcdB0wqNPIkw84fzIIRKE75NoUil9RX0QU,1069
16
- aiwaf-0.1.7.6.dist-info/METADATA,sha256=wzS_EmYIHPo4JULdOAoVZvWn7Yo2I9qrRkcWkHw-k34,6116
17
- aiwaf-0.1.7.6.dist-info/WHEEL,sha256=DnLRTWE75wApRYVsjgc6wsVswC54sMSJhAEd4xhDpBk,91
18
- aiwaf-0.1.7.6.dist-info/top_level.txt,sha256=kU6EyjobT6UPCxuWpI_BvcHDG0I2tMgKaPlWzVxe2xI,6
19
- aiwaf-0.1.7.6.dist-info/RECORD,,
16
+ aiwaf-0.1.7.8.dist-info/licenses/LICENSE,sha256=Ir8PX4dxgAcdB0wqNPIkw84fzIIRKE75NoUil9RX0QU,1069
17
+ aiwaf-0.1.7.8.dist-info/METADATA,sha256=Hthw0o3R1p6JeLo8qfHYKrVCYHB5RgD9lP1zTWlgA8s,6920
18
+ aiwaf-0.1.7.8.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
19
+ aiwaf-0.1.7.8.dist-info/top_level.txt,sha256=kU6EyjobT6UPCxuWpI_BvcHDG0I2tMgKaPlWzVxe2xI,6
20
+ aiwaf-0.1.7.8.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.4.0)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5