oxhunter 2.0.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.
- ai/__init__.py +0 -0
- ai/attack_chaining.py +368 -0
- ai/exploit_generator.py +342 -0
- ai/fp_reducer.py +218 -0
- ai/nl_report.py +256 -0
- ai/payload_generator.py +177 -0
- ai/vuln_chaining.py +197 -0
- asn_scan.py +25 -0
- compliance/bug_bounty.py +128 -0
- compliance/owasp_mapping.py +95 -0
- compliance/pci_iso_report.py +144 -0
- core/__init__.py +0 -0
- core/auth.py +421 -0
- core/config.py +308 -0
- core/context_analyzer.py +198 -0
- core/crawler.py +227 -0
- core/cve_mapping.py +139 -0
- core/headless.py +186 -0
- core/http_client.py +432 -0
- core/logger.py +120 -0
- core/paths.py +19 -0
- core/payload_engine.py +354 -0
- core/pdf_report.py +565 -0
- core/proxy.py +142 -0
- core/rate_limiter.py +50 -0
- core/reporter.py +608 -0
- core/resume.py +127 -0
- core/scan_db.py +523 -0
- core/scanner.py +456 -0
- core/screenshots.py +194 -0
- core/severity.py +178 -0
- core/validator.py +88 -0
- core/waf_detector.py +139 -0
- dashboard_server.py +120 -0
- integrations/burp_export.py +361 -0
- integrations/cicd.py +208 -0
- integrations/jira_github.py +142 -0
- integrations/slack_webhook.py +138 -0
- mass_scan.py +91 -0
- modules/__init__.py +0 -0
- modules/api_versioning.py +194 -0
- modules/business_logic.py +201 -0
- modules/cmd_injection.py +371 -0
- modules/cors.py +309 -0
- modules/csrf.py +136 -0
- modules/directory_brute.py +397 -0
- modules/email_harvest.py +128 -0
- modules/git_exposure.py +401 -0
- modules/graphql.py +555 -0
- modules/headers.py +217 -0
- modules/http_smuggling.py +441 -0
- modules/idor.py +389 -0
- modules/js_analysis.py +502 -0
- modules/jwt_attacks.py +442 -0
- modules/lfi.py +291 -0
- modules/oauth_tester.py +347 -0
- modules/open_redirect.py +207 -0
- modules/password_policy.py +506 -0
- modules/prototype_pollution.py +389 -0
- modules/race_condition.py +323 -0
- modules/session_fixation.py +530 -0
- modules/sqli.py +302 -0
- modules/ssl_tls.py +496 -0
- modules/ssrf.py +303 -0
- modules/subdomain.py +279 -0
- modules/supply_chain.py +306 -0
- modules/tech_fingerprint.py +595 -0
- modules/waf_bypass.py +536 -0
- modules/websocket.py +154 -0
- modules/xss.py +258 -0
- modules/xxe.py +408 -0
- nuclei_integration.py +71 -0
- oxhunter-2.0.0.dist-info/METADATA +656 -0
- oxhunter-2.0.0.dist-info/RECORD +103 -0
- oxhunter-2.0.0.dist-info/WHEEL +5 -0
- oxhunter-2.0.0.dist-info/entry_points.txt +2 -0
- oxhunter-2.0.0.dist-info/licenses/LICENSE +14 -0
- oxhunter-2.0.0.dist-info/top_level.txt +14 -0
- oxhunter_cli.py +36 -0
- oxhunter_main.py +16 -0
- payloads/auth_bypass/auth_bypass.txt +74 -0
- payloads/cmd_injection/cmd_injection.txt +165 -0
- payloads/cors/cors.txt +21 -0
- payloads/csrf/csrf.txt +10 -0
- payloads/graphql/graphql.txt +23 -0
- payloads/http_smuggling/http_smuggling.txt +16 -0
- payloads/idor/idor_params.txt +83 -0
- payloads/jwt/jwt.txt +61 -0
- payloads/lfi/lfi.txt +139 -0
- payloads/open_redirect/open_redirect.txt +68 -0
- payloads/prototype_pollution/prototype_pollution.txt +55 -0
- payloads/sqli/sqli.txt +221 -0
- payloads/ssrf/ssrf.txt +184 -0
- payloads/ssti/ssti.txt +87 -0
- payloads/waf_bypass/waf_bypass.txt +95 -0
- payloads/wordlists/common_dirs.txt +163 -0
- payloads/wordlists/sensitive_files.txt +111 -0
- payloads/wordlists/subdomains.txt +127 -0
- payloads/xss/xss.txt +270 -0
- payloads/xxe/xxe.txt +60 -0
- recon/passive_recon.py +433 -0
- utils/__init__.py +0 -0
- utils/logger.py +111 -0
ai/__init__.py
ADDED
|
File without changes
|
ai/attack_chaining.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OXHUNTER - ai/attack_chaining.py
|
|
3
|
+
Smart Attack Chaining — SSRF→RCE, XSS→CSRF→Account Takeover, etc.
|
|
4
|
+
Uses Groq API (Free)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import json
|
|
9
|
+
import requests
|
|
10
|
+
from typing import Dict, List, Optional
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions"
|
|
14
|
+
GROQ_MODEL = "llama-3.1-70b-versatile"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ─────────────────────────────────────────────
|
|
18
|
+
# KNOWN ATTACK CHAINS
|
|
19
|
+
# ─────────────────────────────────────────────
|
|
20
|
+
KNOWN_CHAINS = {
|
|
21
|
+
("ssrf", "rce"): {
|
|
22
|
+
"name" : "SSRF → RCE",
|
|
23
|
+
"steps" : [
|
|
24
|
+
"SSRF se internal services access karo (Redis, Memcache)",
|
|
25
|
+
"Redis SLAVEOF command inject karo via gopher://",
|
|
26
|
+
"Malicious .so file upload karo",
|
|
27
|
+
"MODULE LOAD se RCE achieve karo",
|
|
28
|
+
],
|
|
29
|
+
"severity": "CRITICAL",
|
|
30
|
+
"cvss" : 9.8,
|
|
31
|
+
},
|
|
32
|
+
("xss", "csrf"): {
|
|
33
|
+
"name" : "XSS → CSRF → Account Takeover",
|
|
34
|
+
"steps" : [
|
|
35
|
+
"XSS payload inject karo victim page mein",
|
|
36
|
+
"JS se admin CSRF-protected endpoint call karo",
|
|
37
|
+
"Admin password ya email change karo",
|
|
38
|
+
"Account takeover complete",
|
|
39
|
+
],
|
|
40
|
+
"severity": "CRITICAL",
|
|
41
|
+
"cvss" : 9.3,
|
|
42
|
+
},
|
|
43
|
+
("xss", "session_fixation"): {
|
|
44
|
+
"name" : "XSS → Session Hijacking",
|
|
45
|
+
"steps" : [
|
|
46
|
+
"XSS se document.cookie steal karo",
|
|
47
|
+
"Cookie attacker server pe send karo",
|
|
48
|
+
"Stolen session use karke login karo",
|
|
49
|
+
],
|
|
50
|
+
"severity": "HIGH",
|
|
51
|
+
"cvss" : 8.8,
|
|
52
|
+
},
|
|
53
|
+
("open_redirect", "xss"): {
|
|
54
|
+
"name" : "Open Redirect → XSS",
|
|
55
|
+
"steps" : [
|
|
56
|
+
"Open redirect pe javascript: URI inject karo",
|
|
57
|
+
"Victim redirect pe XSS trigger ho",
|
|
58
|
+
"Session/credentials steal karo",
|
|
59
|
+
],
|
|
60
|
+
"severity": "HIGH",
|
|
61
|
+
"cvss" : 7.5,
|
|
62
|
+
},
|
|
63
|
+
("lfi", "rce"): {
|
|
64
|
+
"name" : "LFI → Log Poisoning → RCE",
|
|
65
|
+
"steps" : [
|
|
66
|
+
"LFI se /var/log/apache2/access.log read karo",
|
|
67
|
+
"User-Agent mein PHP code inject karo",
|
|
68
|
+
"LFI se poisoned log include karo",
|
|
69
|
+
"RCE achieve karo",
|
|
70
|
+
],
|
|
71
|
+
"severity": "CRITICAL",
|
|
72
|
+
"cvss" : 9.8,
|
|
73
|
+
},
|
|
74
|
+
("sqli", "rce"): {
|
|
75
|
+
"name" : "SQLi → File Write → RCE",
|
|
76
|
+
"steps" : [
|
|
77
|
+
"SQLi se FILE privilege check karo",
|
|
78
|
+
"INTO OUTFILE se webshell likho",
|
|
79
|
+
"Webshell access karke RCE karo",
|
|
80
|
+
],
|
|
81
|
+
"severity": "CRITICAL",
|
|
82
|
+
"cvss" : 9.8,
|
|
83
|
+
},
|
|
84
|
+
("xxe", "ssrf"): {
|
|
85
|
+
"name" : "XXE → SSRF → Cloud Metadata",
|
|
86
|
+
"steps" : [
|
|
87
|
+
"XXE payload se http:// entity define karo",
|
|
88
|
+
"AWS/GCP metadata endpoint target karo",
|
|
89
|
+
"IAM credentials steal karo",
|
|
90
|
+
"Cloud infrastructure access karo",
|
|
91
|
+
],
|
|
92
|
+
"severity": "CRITICAL",
|
|
93
|
+
"cvss" : 9.1,
|
|
94
|
+
},
|
|
95
|
+
("cors", "csrf"): {
|
|
96
|
+
"name" : "CORS → Cross-Origin Data Theft",
|
|
97
|
+
"steps" : [
|
|
98
|
+
"CORS misconfiguration se cross-origin request karo",
|
|
99
|
+
"Authenticated user ke private data read karo",
|
|
100
|
+
"Sensitive API endpoints call karo",
|
|
101
|
+
],
|
|
102
|
+
"severity": "HIGH",
|
|
103
|
+
"cvss" : 8.1,
|
|
104
|
+
},
|
|
105
|
+
("jwt_attacks", "privilege_escalation"): {
|
|
106
|
+
"name" : "JWT Attack → Admin Access",
|
|
107
|
+
"steps" : [
|
|
108
|
+
"JWT token intercept karo",
|
|
109
|
+
"alg:none attack ya weak secret brute-force karo",
|
|
110
|
+
"role/isAdmin claim modify karo",
|
|
111
|
+
"Admin endpoints access karo",
|
|
112
|
+
],
|
|
113
|
+
"severity": "CRITICAL",
|
|
114
|
+
"cvss" : 9.8,
|
|
115
|
+
},
|
|
116
|
+
("prototype_pollution", "xss"): {
|
|
117
|
+
"name" : "Prototype Pollution → XSS",
|
|
118
|
+
"steps" : [
|
|
119
|
+
"Object prototype pollute karo",
|
|
120
|
+
"innerHTML ya eval-based sink trigger karo",
|
|
121
|
+
"XSS execute karo",
|
|
122
|
+
],
|
|
123
|
+
"severity": "HIGH",
|
|
124
|
+
"cvss" : 8.0,
|
|
125
|
+
},
|
|
126
|
+
("idor", "privilege_escalation"): {
|
|
127
|
+
"name" : "IDOR → Privilege Escalation",
|
|
128
|
+
"steps" : [
|
|
129
|
+
"IDOR se admin user ID access karo",
|
|
130
|
+
"Admin profile data modify karo",
|
|
131
|
+
"Privilege escalation complete",
|
|
132
|
+
],
|
|
133
|
+
"severity": "HIGH",
|
|
134
|
+
"cvss" : 8.5,
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ─────────────────────────────────────────────
|
|
140
|
+
# DATACLASS
|
|
141
|
+
# ─────────────────────────────────────────────
|
|
142
|
+
@dataclass
|
|
143
|
+
class AttackChain:
|
|
144
|
+
name : str
|
|
145
|
+
vulns : List[str]
|
|
146
|
+
steps : List[str]
|
|
147
|
+
severity : str
|
|
148
|
+
cvss : float
|
|
149
|
+
target_url : str
|
|
150
|
+
ai_analysis : str = ""
|
|
151
|
+
poc_outline : str = ""
|
|
152
|
+
impact : str = ""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ─────────────────────────────────────────────
|
|
156
|
+
# SMART ATTACK CHAINER
|
|
157
|
+
# ─────────────────────────────────────────────
|
|
158
|
+
class SmartAttackChainer:
|
|
159
|
+
"""
|
|
160
|
+
Automatically identifies and chains vulnerabilities
|
|
161
|
+
for maximum impact exploitation paths.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(self, api_key: Optional[str] = None):
|
|
165
|
+
self.api_key = api_key or os.getenv("GROQ_API_KEY", "")
|
|
166
|
+
self.headers = {
|
|
167
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
168
|
+
"Content-Type" : "application/json",
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
def _groq(self, prompt: str, max_tokens: int = 800) -> str:
|
|
172
|
+
if not self.api_key:
|
|
173
|
+
return ""
|
|
174
|
+
try:
|
|
175
|
+
r = requests.post(
|
|
176
|
+
GROQ_API_URL,
|
|
177
|
+
headers=self.headers,
|
|
178
|
+
json={
|
|
179
|
+
"model" : GROQ_MODEL,
|
|
180
|
+
"messages" : [{"role": "user", "content": prompt}],
|
|
181
|
+
"max_tokens" : max_tokens,
|
|
182
|
+
"temperature": 0.2,
|
|
183
|
+
},
|
|
184
|
+
timeout=30
|
|
185
|
+
)
|
|
186
|
+
if r.status_code == 200:
|
|
187
|
+
return r.json()["choices"][0]["message"]["content"].strip()
|
|
188
|
+
except Exception:
|
|
189
|
+
pass
|
|
190
|
+
return ""
|
|
191
|
+
|
|
192
|
+
# ── Local Chaining ────────────────────────
|
|
193
|
+
|
|
194
|
+
def find_chains(self, findings: List[Dict]) -> List[AttackChain]:
|
|
195
|
+
"""
|
|
196
|
+
Find attack chains from scan findings (no API needed).
|
|
197
|
+
Uses built-in KNOWN_CHAINS database.
|
|
198
|
+
"""
|
|
199
|
+
vuln_types = list(set(
|
|
200
|
+
f.get("type", f.get("vuln_type", "")).lower()
|
|
201
|
+
for f in findings
|
|
202
|
+
))
|
|
203
|
+
target_url = findings[0].get("url", "") if findings else ""
|
|
204
|
+
chains = []
|
|
205
|
+
|
|
206
|
+
for (v1, v2), chain_data in KNOWN_CHAINS.items():
|
|
207
|
+
if v1 in vuln_types and (v2 in vuln_types or v2 in ["rce","privilege_escalation"]):
|
|
208
|
+
chain = AttackChain(
|
|
209
|
+
name = chain_data["name"],
|
|
210
|
+
vulns = [v1, v2],
|
|
211
|
+
steps = chain_data["steps"],
|
|
212
|
+
severity = chain_data["severity"],
|
|
213
|
+
cvss = chain_data["cvss"],
|
|
214
|
+
target_url= target_url,
|
|
215
|
+
impact = f"Chain: {v1.upper()} → {v2.upper()} can lead to full compromise",
|
|
216
|
+
)
|
|
217
|
+
chains.append(chain)
|
|
218
|
+
|
|
219
|
+
# Sort by CVSS score
|
|
220
|
+
chains.sort(key=lambda c: c.cvss, reverse=True)
|
|
221
|
+
return chains
|
|
222
|
+
|
|
223
|
+
# ── AI-Enhanced Chaining ──────────────────
|
|
224
|
+
|
|
225
|
+
def ai_chain_analysis(self, findings: List[Dict]) -> List[AttackChain]:
|
|
226
|
+
"""
|
|
227
|
+
Use Groq AI to find creative attack chains
|
|
228
|
+
beyond the known patterns.
|
|
229
|
+
"""
|
|
230
|
+
chains = self.find_chains(findings)
|
|
231
|
+
|
|
232
|
+
if not self.api_key or not findings:
|
|
233
|
+
return chains
|
|
234
|
+
|
|
235
|
+
vuln_summary = ", ".join(set(
|
|
236
|
+
f.get("type", f.get("vuln_type","")).upper()
|
|
237
|
+
for f in findings
|
|
238
|
+
))
|
|
239
|
+
target = findings[0].get("url","") if findings else ""
|
|
240
|
+
|
|
241
|
+
prompt = f"""You are a senior penetration tester analyzing vulnerabilities.
|
|
242
|
+
|
|
243
|
+
Target: {target}
|
|
244
|
+
Vulnerabilities found: {vuln_summary}
|
|
245
|
+
|
|
246
|
+
Identify attack chains that combine these vulnerabilities for maximum impact.
|
|
247
|
+
Return as JSON array with this exact format:
|
|
248
|
+
[
|
|
249
|
+
{{
|
|
250
|
+
"name": "Chain Name",
|
|
251
|
+
"vulns": ["vuln1", "vuln2"],
|
|
252
|
+
"steps": ["step1", "step2", "step3"],
|
|
253
|
+
"severity": "CRITICAL",
|
|
254
|
+
"cvss": 9.8,
|
|
255
|
+
"impact": "What attacker can achieve"
|
|
256
|
+
}}
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
Return ONLY the JSON array, nothing else."""
|
|
260
|
+
|
|
261
|
+
raw = self._groq(prompt, max_tokens=1000)
|
|
262
|
+
if not raw:
|
|
263
|
+
return chains
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
start = raw.find("[")
|
|
267
|
+
end = raw.rfind("]") + 1
|
|
268
|
+
if start == -1:
|
|
269
|
+
return chains
|
|
270
|
+
|
|
271
|
+
ai_chains = json.loads(raw[start:end])
|
|
272
|
+
for ac in ai_chains:
|
|
273
|
+
# Check for duplicates
|
|
274
|
+
if ac.get("name") not in [c.name for c in chains]:
|
|
275
|
+
chains.append(AttackChain(
|
|
276
|
+
name = ac.get("name", "Unknown Chain"),
|
|
277
|
+
vulns = ac.get("vulns", []),
|
|
278
|
+
steps = ac.get("steps", []),
|
|
279
|
+
severity = ac.get("severity", "HIGH"),
|
|
280
|
+
cvss = float(ac.get("cvss", 7.0)),
|
|
281
|
+
target_url= target,
|
|
282
|
+
impact = ac.get("impact", ""),
|
|
283
|
+
ai_analysis = "AI-discovered chain",
|
|
284
|
+
))
|
|
285
|
+
except Exception:
|
|
286
|
+
pass
|
|
287
|
+
|
|
288
|
+
chains.sort(key=lambda c: c.cvss, reverse=True)
|
|
289
|
+
return chains
|
|
290
|
+
|
|
291
|
+
def generate_poc_outline(self, chain: AttackChain) -> str:
|
|
292
|
+
"""Generate attack PoC outline for a chain."""
|
|
293
|
+
if not self.api_key:
|
|
294
|
+
return "\n".join(f"{i+1}. {s}" for i, s in enumerate(chain.steps))
|
|
295
|
+
|
|
296
|
+
prompt = f"""Write a brief PoC outline for this attack chain (authorized testing only):
|
|
297
|
+
|
|
298
|
+
Chain: {chain.name}
|
|
299
|
+
Target: {chain.target_url}
|
|
300
|
+
Vulnerabilities: {' → '.join(chain.vulns)}
|
|
301
|
+
|
|
302
|
+
Steps:
|
|
303
|
+
{chr(10).join(f'{i+1}. {s}' for i,s in enumerate(chain.steps))}
|
|
304
|
+
|
|
305
|
+
Write 5-8 lines of pseudocode or commands showing how to execute this chain.
|
|
306
|
+
Keep it concise and technical."""
|
|
307
|
+
|
|
308
|
+
return self._groq(prompt, max_tokens=400)
|
|
309
|
+
|
|
310
|
+
# ── Report ────────────────────────────────
|
|
311
|
+
|
|
312
|
+
def summary(self, chains: List[AttackChain]) -> Dict:
|
|
313
|
+
return {
|
|
314
|
+
"total_chains" : len(chains),
|
|
315
|
+
"critical_chains": sum(1 for c in chains if c.severity == "CRITICAL"),
|
|
316
|
+
"highest_cvss" : max((c.cvss for c in chains), default=0),
|
|
317
|
+
"chains" : [
|
|
318
|
+
{
|
|
319
|
+
"name" : c.name,
|
|
320
|
+
"severity": c.severity,
|
|
321
|
+
"cvss" : c.cvss,
|
|
322
|
+
"vulns" : c.vulns,
|
|
323
|
+
"steps" : c.steps,
|
|
324
|
+
"impact" : c.impact,
|
|
325
|
+
}
|
|
326
|
+
for c in chains
|
|
327
|
+
],
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
def html_report(self, chains: List[AttackChain], target: str = "") -> str:
|
|
331
|
+
"""Generate HTML attack chain report."""
|
|
332
|
+
if not chains:
|
|
333
|
+
return "<p>No attack chains identified.</p>"
|
|
334
|
+
|
|
335
|
+
cards = ""
|
|
336
|
+
for i, c in enumerate(chains, 1):
|
|
337
|
+
color = {
|
|
338
|
+
"CRITICAL": "#dc2626", "HIGH": "#ea580c",
|
|
339
|
+
"MEDIUM" : "#d97706", "LOW" : "#2563eb"
|
|
340
|
+
}.get(c.severity, "#6b7280")
|
|
341
|
+
|
|
342
|
+
steps_html = "".join(f"<li style='color:#94a3b8'>{s}</li>" for s in c.steps)
|
|
343
|
+
vulns_html = " → ".join(
|
|
344
|
+
f"<span style='background:{color};color:white;padding:2px 6px;border-radius:3px;font-size:11px'>{v.upper()}</span>"
|
|
345
|
+
for v in c.vulns
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
cards += f"""
|
|
349
|
+
<div style="background:#1e293b;border-radius:8px;margin:16px 0;border-left:4px solid {color};padding:16px">
|
|
350
|
+
<div style="margin-bottom:10px">
|
|
351
|
+
<span style="background:{color};color:white;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:bold">{c.severity} | CVSS:{c.cvss}</span>
|
|
352
|
+
<strong style="color:white;margin-left:10px">#{i} {c.name}</strong>
|
|
353
|
+
</div>
|
|
354
|
+
<div style="margin-bottom:10px">{vulns_html}</div>
|
|
355
|
+
<p style="color:#94a3b8;margin:0 0 8px"><strong style="color:#e2e8f0">Impact:</strong> {c.impact}</p>
|
|
356
|
+
<p style="color:#e2e8f0;margin:0 0 8px"><strong>Attack Steps:</strong></p>
|
|
357
|
+
<ol style="margin:0">{steps_html}</ol>
|
|
358
|
+
</div>"""
|
|
359
|
+
|
|
360
|
+
return f"""<!DOCTYPE html>
|
|
361
|
+
<html><head><meta charset="UTF-8"><title>Attack Chains</title>
|
|
362
|
+
<style>body{{font-family:'Segoe UI',sans-serif;background:#0f172a;color:#e2e8f0;padding:20px;max-width:1000px;margin:0 auto}}</style>
|
|
363
|
+
</head><body>
|
|
364
|
+
<h1 style="color:#f87171">⛓️ OXHUNTER — Attack Chain Analysis</h1>
|
|
365
|
+
<p style="color:#94a3b8">Target: {target} | Chains Found: {len(chains)}</p>
|
|
366
|
+
<p style="color:#ef4444;border:1px solid #ef4444;padding:8px;border-radius:4px">⚠️ FOR AUTHORIZED TESTING ONLY</p>
|
|
367
|
+
{cards}
|
|
368
|
+
</body></html>"""
|