stryx-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stryx/__init__.py +35 -0
- stryx/ai/__init__.py +5 -0
- stryx/ai/attack_planner.py +199 -0
- stryx/ai/payload_generator.py +212 -0
- stryx/ai/prompts.py +85 -0
- stryx/ai/providers.py +249 -0
- stryx/attacks/__init__.py +6 -0
- stryx/attacks/attack_chain.py +111 -0
- stryx/attacks/replay.py +186 -0
- stryx/auth/__init__.py +1 -0
- stryx/auth/session_manager.py +335 -0
- stryx/cli.py +675 -0
- stryx/comparison/__init__.py +1 -0
- stryx/comparison/differ.py +255 -0
- stryx/config/__init__.py +6 -0
- stryx/config/default_config.yaml +12 -0
- stryx/config/loader.py +111 -0
- stryx/config/schema.py +80 -0
- stryx/crawler/__init__.py +5 -0
- stryx/crawler/discovery.py +178 -0
- stryx/crawler/graphql_discovery.py +86 -0
- stryx/crawler/html_crawler.py +294 -0
- stryx/crawler/js_endpoints.py +165 -0
- stryx/crawler/openapi.py +76 -0
- stryx/crawler/sitemap.py +94 -0
- stryx/orchestrator.py +347 -0
- stryx/payloads/cmdi.txt +31 -0
- stryx/payloads/header_injection.txt +15 -0
- stryx/payloads/ldap.txt +19 -0
- stryx/payloads/nosqli.txt +21 -0
- stryx/payloads/open_redirect.txt +23 -0
- stryx/payloads/path_traversal.txt +26 -0
- stryx/payloads/sqli.txt +31 -0
- stryx/payloads/ssrf.txt +30 -0
- stryx/payloads/ssti.txt +21 -0
- stryx/payloads/xss.txt +48 -0
- stryx/payloads/xxe.txt +35 -0
- stryx/plugins/__init__.py +5 -0
- stryx/plugins/base.py +85 -0
- stryx/policy/__init__.py +1 -0
- stryx/policy/engine.py +201 -0
- stryx/py.typed +0 -0
- stryx/reports/__init__.py +5 -0
- stryx/reports/generator.py +122 -0
- stryx/reports/json_report.py +72 -0
- stryx/reports/markdown_report.py +101 -0
- stryx/reports/sarif_report.py +200 -0
- stryx/reports/templates/report.html.j2 +163 -0
- stryx/reports/terminal_report.py +138 -0
- stryx/scanners/__init__.py +17 -0
- stryx/scanners/auth.py +444 -0
- stryx/scanners/authorization.py +220 -0
- stryx/scanners/blind.py +328 -0
- stryx/scanners/cloud_ssrf.py +208 -0
- stryx/scanners/cors.py +158 -0
- stryx/scanners/dependencies.py +296 -0
- stryx/scanners/disclosure.py +339 -0
- stryx/scanners/fuzz.py +391 -0
- stryx/scanners/graphql.py +309 -0
- stryx/scanners/injection.py +511 -0
- stryx/scanners/race.py +257 -0
- stryx/signatures/framework_fingerprints.yaml +122 -0
- stryx/signatures/known_vulns.yaml +210 -0
- stryx/utils/__init__.py +7 -0
- stryx/utils/evidence.py +109 -0
- stryx/utils/http_client.py +159 -0
- stryx/utils/logging.py +51 -0
- stryx/utils/rate_limiter.py +37 -0
- stryx_cli-0.1.0.dist-info/METADATA +236 -0
- stryx_cli-0.1.0.dist-info/RECORD +74 -0
- stryx_cli-0.1.0.dist-info/WHEEL +5 -0
- stryx_cli-0.1.0.dist-info/entry_points.txt +2 -0
- stryx_cli-0.1.0.dist-info/licenses/LICENSE +190 -0
- stryx_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Session state machine for authenticated scanning.
|
|
2
|
+
|
|
3
|
+
Handles auto-detection of login forms, session cookie persistence,
|
|
4
|
+
JWT/OAuth token refresh, and multi-step authentication flows.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from stryx.utils.http_client import HttpClient
|
|
16
|
+
from stryx.utils.logging import get_logger
|
|
17
|
+
|
|
18
|
+
logger = get_logger("auth.session")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class LoginEndpoint:
|
|
23
|
+
"""Detected login endpoint."""
|
|
24
|
+
|
|
25
|
+
url: str
|
|
26
|
+
method: str = "POST"
|
|
27
|
+
username_field: str = "username"
|
|
28
|
+
password_field: str = "password"
|
|
29
|
+
additional_fields: dict[str, str] = field(default_factory=dict)
|
|
30
|
+
content_type: str = "application/json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SessionManager:
|
|
34
|
+
"""Manages authentication state for DAST scanning.
|
|
35
|
+
|
|
36
|
+
Features:
|
|
37
|
+
- Auto-detect login forms from HTML responses
|
|
38
|
+
- Maintain session cookies across requests
|
|
39
|
+
- JWT/OAuth token refresh
|
|
40
|
+
- Multi-step authentication flows
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
client: HttpClient,
|
|
46
|
+
base_url: str,
|
|
47
|
+
username: str | None = None,
|
|
48
|
+
password: str | None = None,
|
|
49
|
+
login_url: str | None = None,
|
|
50
|
+
headers: dict[str, str] | None = None,
|
|
51
|
+
):
|
|
52
|
+
self.client = client
|
|
53
|
+
self.base_url = base_url.rstrip("/")
|
|
54
|
+
self.username = username
|
|
55
|
+
self.password = password
|
|
56
|
+
self.login_url = login_url
|
|
57
|
+
self.custom_headers = headers or {}
|
|
58
|
+
self._authenticated = False
|
|
59
|
+
self._session_token: str | None = None
|
|
60
|
+
self._token_expiry: float = 0
|
|
61
|
+
self._login_endpoints: list[LoginEndpoint] = []
|
|
62
|
+
self._session_data: dict[str, Any] = {}
|
|
63
|
+
|
|
64
|
+
async def setup(self) -> bool:
|
|
65
|
+
"""Detect login endpoints and attempt authentication.
|
|
66
|
+
|
|
67
|
+
Returns True if authentication was successful or not needed.
|
|
68
|
+
"""
|
|
69
|
+
if not self.username or not self.password:
|
|
70
|
+
logger.info("No credentials provided, skipping authentication")
|
|
71
|
+
self._authenticated = True
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
# Discover login endpoints
|
|
75
|
+
await self._discover_login_endpoints()
|
|
76
|
+
|
|
77
|
+
if not self.login_url and not self._login_endpoints:
|
|
78
|
+
logger.warning("No login endpoints found")
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
# Attempt login
|
|
82
|
+
return await self.login()
|
|
83
|
+
|
|
84
|
+
async def _discover_login_endpoints(self) -> None:
|
|
85
|
+
"""Crawl the target to find login forms."""
|
|
86
|
+
logger.info("Discovering login endpoints")
|
|
87
|
+
|
|
88
|
+
# Check explicit login URL first
|
|
89
|
+
if self.login_url:
|
|
90
|
+
try:
|
|
91
|
+
response, _ = await self.client.get(
|
|
92
|
+
self._ensure_absolute(self.login_url)
|
|
93
|
+
)
|
|
94
|
+
if response.status_code == 200:
|
|
95
|
+
endpoints = self._extract_login_forms(response.text, self.login_url)
|
|
96
|
+
self._login_endpoints.extend(endpoints)
|
|
97
|
+
except Exception as e:
|
|
98
|
+
logger.debug(f"Failed to check login URL: {e}")
|
|
99
|
+
|
|
100
|
+
# Crawl common login paths
|
|
101
|
+
login_paths = ["/login", "/signin", "/auth/login", "/api/auth/login",
|
|
102
|
+
"/api/login", "/account/login", "/wp-login.php"]
|
|
103
|
+
for path in login_paths:
|
|
104
|
+
url = f"{self.base_url}{path}"
|
|
105
|
+
try:
|
|
106
|
+
response, _ = await self.client.get(url)
|
|
107
|
+
if response.status_code == 200:
|
|
108
|
+
endpoints = self._extract_login_forms(response.text, path)
|
|
109
|
+
self._login_endpoints.extend(endpoints)
|
|
110
|
+
if endpoints:
|
|
111
|
+
logger.info(f"Found login form at {path}")
|
|
112
|
+
except Exception:
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
logger.info(f"Discovered {len(self._login_endpoints)} login endpoint(s)")
|
|
116
|
+
|
|
117
|
+
def _extract_login_forms(self, html: str, page_path: str) -> list[LoginEndpoint]:
|
|
118
|
+
"""Extract login form details from HTML."""
|
|
119
|
+
endpoints: list[LoginEndpoint] = []
|
|
120
|
+
base = self.base_url
|
|
121
|
+
|
|
122
|
+
# Find <form> tags
|
|
123
|
+
form_pattern = re.compile(
|
|
124
|
+
r'<form[^>]*action=["\']([^"\']*)["\'][^>]*method=["\']?(\w+)["\']?[^>]*>',
|
|
125
|
+
re.IGNORECASE | re.DOTALL,
|
|
126
|
+
)
|
|
127
|
+
input_pattern = re.compile(
|
|
128
|
+
r'<input[^>]*name=["\']([^"\']+)["\'][^>]*(?:type=["\'](\w+)["\'])?[^>]*>',
|
|
129
|
+
re.IGNORECASE,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
forms = form_pattern.findall(html)
|
|
133
|
+
for action, method in forms:
|
|
134
|
+
# Get form content (simplified — find the form body)
|
|
135
|
+
form_start = html.lower().find(f'action="{action}"')
|
|
136
|
+
if form_start == -1:
|
|
137
|
+
form_start = html.lower().find(f'action=\'{action}\'')
|
|
138
|
+
if form_start == -1:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
# Find the closing </form> tag
|
|
142
|
+
form_end = html.lower().find("</form>", form_start)
|
|
143
|
+
if form_end == -1:
|
|
144
|
+
form_end = form_start + 2000
|
|
145
|
+
form_html = html[form_start:form_end]
|
|
146
|
+
|
|
147
|
+
# Extract input fields
|
|
148
|
+
inputs = input_pattern.findall(form_html)
|
|
149
|
+
field_names = [name for name, _ in inputs]
|
|
150
|
+
field_types = {name: typ for name, typ in inputs}
|
|
151
|
+
|
|
152
|
+
# Check if this looks like a login form
|
|
153
|
+
password_fields = [f for f in field_names if field_types.get(f) == "password"]
|
|
154
|
+
text_fields = [f for f in field_names if field_types.get(f) in ("text", "email", "")]
|
|
155
|
+
|
|
156
|
+
if password_fields and text_fields:
|
|
157
|
+
# Determine username and password field names
|
|
158
|
+
username_field = text_fields[0]
|
|
159
|
+
password_field = password_fields[0]
|
|
160
|
+
|
|
161
|
+
# Build full URL
|
|
162
|
+
if action.startswith("http"):
|
|
163
|
+
form_url = action
|
|
164
|
+
elif action.startswith("/"):
|
|
165
|
+
form_url = f"{base}{action}"
|
|
166
|
+
else:
|
|
167
|
+
form_url = f"{base}/{page_path.rsplit('/', 1)[0]}/{action}" if "/" in page_path else f"{base}/{action}"
|
|
168
|
+
|
|
169
|
+
# Determine content type
|
|
170
|
+
content_type = "application/json"
|
|
171
|
+
if "multipart" in form_html.lower() or "form-data" in form_html.lower():
|
|
172
|
+
content_type = "multipart/form-data"
|
|
173
|
+
elif "urlencoded" in form_html.lower():
|
|
174
|
+
content_type = "application/x-www-form-urlencoded"
|
|
175
|
+
|
|
176
|
+
endpoints.append(LoginEndpoint(
|
|
177
|
+
url=form_url,
|
|
178
|
+
method=method.upper(),
|
|
179
|
+
username_field=username_field,
|
|
180
|
+
password_field=password_field,
|
|
181
|
+
content_type=content_type,
|
|
182
|
+
))
|
|
183
|
+
|
|
184
|
+
return endpoints
|
|
185
|
+
|
|
186
|
+
async def login(self) -> bool:
|
|
187
|
+
"""Attempt to login using discovered or configured credentials."""
|
|
188
|
+
if not self.username or not self.password:
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
for endpoint in self._login_endpoints:
|
|
192
|
+
success = await self._try_login(endpoint)
|
|
193
|
+
if success:
|
|
194
|
+
self._authenticated = True
|
|
195
|
+
logger.info(f"Successfully authenticated via {endpoint.url}")
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
# Try explicit login URL with common field names
|
|
199
|
+
if self.login_url:
|
|
200
|
+
for username_field in ["username", "email", "user", "login"]:
|
|
201
|
+
for password_field in ["password", "pass", "passwd"]:
|
|
202
|
+
endpoint = LoginEndpoint(
|
|
203
|
+
url=self._ensure_absolute(self.login_url),
|
|
204
|
+
method="POST",
|
|
205
|
+
username_field=username_field,
|
|
206
|
+
password_field=password_field,
|
|
207
|
+
)
|
|
208
|
+
success = await self._try_login(endpoint)
|
|
209
|
+
if success:
|
|
210
|
+
self._authenticated = True
|
|
211
|
+
logger.info(f"Successfully authenticated via {self.login_url}")
|
|
212
|
+
return True
|
|
213
|
+
|
|
214
|
+
logger.warning("All login attempts failed")
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
async def _try_login(self, endpoint: LoginEndpoint) -> bool:
|
|
218
|
+
"""Try logging in with a specific endpoint configuration."""
|
|
219
|
+
try:
|
|
220
|
+
payload = {
|
|
221
|
+
endpoint.username_field: self.username,
|
|
222
|
+
endpoint.password_field: self.password,
|
|
223
|
+
}
|
|
224
|
+
# Add any additional fields
|
|
225
|
+
payload.update(endpoint.additional_fields)
|
|
226
|
+
|
|
227
|
+
headers = {**self.custom_headers}
|
|
228
|
+
if endpoint.content_type == "application/json":
|
|
229
|
+
headers["Content-Type"] = "application/json"
|
|
230
|
+
body = json.dumps(payload)
|
|
231
|
+
elif endpoint.content_type == "application/x-www-form-urlencoded":
|
|
232
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
233
|
+
body = "&".join(f"{k}={v}" for k, v in payload.items())
|
|
234
|
+
else:
|
|
235
|
+
headers["Content-Type"] = "application/json"
|
|
236
|
+
body = json.dumps(payload)
|
|
237
|
+
|
|
238
|
+
response, evidence = await self.client.request(
|
|
239
|
+
method=endpoint.method,
|
|
240
|
+
url=endpoint.url,
|
|
241
|
+
headers=headers,
|
|
242
|
+
body=body,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# Check if login was successful
|
|
246
|
+
if response.status_code in (200, 201, 302):
|
|
247
|
+
# Check for token in response
|
|
248
|
+
try:
|
|
249
|
+
resp_data = json.loads(response.text)
|
|
250
|
+
if "token" in resp_data:
|
|
251
|
+
self._session_token = resp_data["token"]
|
|
252
|
+
self._token_expiry = time.time() + 3600 # Default 1hr
|
|
253
|
+
self._session_data = resp_data
|
|
254
|
+
return True
|
|
255
|
+
if "access_token" in resp_data:
|
|
256
|
+
self._session_token = resp_data["access_token"]
|
|
257
|
+
self._token_expiry = time.time() + resp_data.get("expires_in", 3600)
|
|
258
|
+
self._session_data = resp_data
|
|
259
|
+
return True
|
|
260
|
+
except (json.JSONDecodeError, KeyError):
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
# Check for session cookies
|
|
264
|
+
if response.cookies:
|
|
265
|
+
self._session_data["cookies"] = dict(response.cookies)
|
|
266
|
+
return True
|
|
267
|
+
|
|
268
|
+
# Check for redirect (successful login often redirects)
|
|
269
|
+
if response.status_code == 302:
|
|
270
|
+
return True
|
|
271
|
+
|
|
272
|
+
except Exception as e:
|
|
273
|
+
logger.debug(f"Login attempt failed for {endpoint.url}: {e}")
|
|
274
|
+
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
async def refresh(self) -> bool:
|
|
278
|
+
"""Refresh the session token if expired."""
|
|
279
|
+
if not self._session_token:
|
|
280
|
+
return await self.login()
|
|
281
|
+
|
|
282
|
+
if time.time() < self._token_expiry - 60: # Refresh 1 min before expiry
|
|
283
|
+
return True
|
|
284
|
+
|
|
285
|
+
logger.info("Token expired, refreshing...")
|
|
286
|
+
# Try to refresh the token
|
|
287
|
+
refresh_token = self._session_data.get("refresh_token")
|
|
288
|
+
if refresh_token:
|
|
289
|
+
try:
|
|
290
|
+
response, _ = await self.client.request(
|
|
291
|
+
method="POST",
|
|
292
|
+
url=f"{self.base_url}/api/auth/refresh",
|
|
293
|
+
headers={"Content-Type": "application/json"},
|
|
294
|
+
body=json.dumps({"refresh_token": refresh_token}),
|
|
295
|
+
)
|
|
296
|
+
if response.status_code == 200:
|
|
297
|
+
data = json.loads(response.text)
|
|
298
|
+
self._session_token = data.get("token") or data.get("access_token")
|
|
299
|
+
self._token_expiry = time.time() + data.get("expires_in", 3600)
|
|
300
|
+
return True
|
|
301
|
+
except Exception as e:
|
|
302
|
+
logger.debug(f"Token refresh failed: {e}")
|
|
303
|
+
|
|
304
|
+
# Fallback: re-login
|
|
305
|
+
return await self.login()
|
|
306
|
+
|
|
307
|
+
def is_authenticated(self) -> bool:
|
|
308
|
+
"""Check if we have an active authenticated session."""
|
|
309
|
+
return self._authenticated
|
|
310
|
+
|
|
311
|
+
def get_session_headers(self) -> dict[str, str]:
|
|
312
|
+
"""Get headers to include in authenticated requests."""
|
|
313
|
+
headers = {**self.custom_headers}
|
|
314
|
+
|
|
315
|
+
if self._session_token:
|
|
316
|
+
headers["Authorization"] = f"Bearer {self._session_token}"
|
|
317
|
+
|
|
318
|
+
cookies = self._session_data.get("cookies", {})
|
|
319
|
+
if cookies:
|
|
320
|
+
cookie_str = "; ".join(f"{k}={v}" for k, v in cookies.items())
|
|
321
|
+
headers["Cookie"] = cookie_str
|
|
322
|
+
|
|
323
|
+
return headers
|
|
324
|
+
|
|
325
|
+
def get_session_cookies(self) -> dict[str, str]:
|
|
326
|
+
"""Get session cookies as a dictionary."""
|
|
327
|
+
return self._session_data.get("cookies", {})
|
|
328
|
+
|
|
329
|
+
def _ensure_absolute(self, url: str) -> str:
|
|
330
|
+
"""Ensure a URL is absolute."""
|
|
331
|
+
if url.startswith("http"):
|
|
332
|
+
return url
|
|
333
|
+
if url.startswith("/"):
|
|
334
|
+
return f"{self.base_url}{url}"
|
|
335
|
+
return f"{self.base_url}/{url}"
|