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.
Files changed (74) hide show
  1. stryx/__init__.py +35 -0
  2. stryx/ai/__init__.py +5 -0
  3. stryx/ai/attack_planner.py +199 -0
  4. stryx/ai/payload_generator.py +212 -0
  5. stryx/ai/prompts.py +85 -0
  6. stryx/ai/providers.py +249 -0
  7. stryx/attacks/__init__.py +6 -0
  8. stryx/attacks/attack_chain.py +111 -0
  9. stryx/attacks/replay.py +186 -0
  10. stryx/auth/__init__.py +1 -0
  11. stryx/auth/session_manager.py +335 -0
  12. stryx/cli.py +675 -0
  13. stryx/comparison/__init__.py +1 -0
  14. stryx/comparison/differ.py +255 -0
  15. stryx/config/__init__.py +6 -0
  16. stryx/config/default_config.yaml +12 -0
  17. stryx/config/loader.py +111 -0
  18. stryx/config/schema.py +80 -0
  19. stryx/crawler/__init__.py +5 -0
  20. stryx/crawler/discovery.py +178 -0
  21. stryx/crawler/graphql_discovery.py +86 -0
  22. stryx/crawler/html_crawler.py +294 -0
  23. stryx/crawler/js_endpoints.py +165 -0
  24. stryx/crawler/openapi.py +76 -0
  25. stryx/crawler/sitemap.py +94 -0
  26. stryx/orchestrator.py +347 -0
  27. stryx/payloads/cmdi.txt +31 -0
  28. stryx/payloads/header_injection.txt +15 -0
  29. stryx/payloads/ldap.txt +19 -0
  30. stryx/payloads/nosqli.txt +21 -0
  31. stryx/payloads/open_redirect.txt +23 -0
  32. stryx/payloads/path_traversal.txt +26 -0
  33. stryx/payloads/sqli.txt +31 -0
  34. stryx/payloads/ssrf.txt +30 -0
  35. stryx/payloads/ssti.txt +21 -0
  36. stryx/payloads/xss.txt +48 -0
  37. stryx/payloads/xxe.txt +35 -0
  38. stryx/plugins/__init__.py +5 -0
  39. stryx/plugins/base.py +85 -0
  40. stryx/policy/__init__.py +1 -0
  41. stryx/policy/engine.py +201 -0
  42. stryx/py.typed +0 -0
  43. stryx/reports/__init__.py +5 -0
  44. stryx/reports/generator.py +122 -0
  45. stryx/reports/json_report.py +72 -0
  46. stryx/reports/markdown_report.py +101 -0
  47. stryx/reports/sarif_report.py +200 -0
  48. stryx/reports/templates/report.html.j2 +163 -0
  49. stryx/reports/terminal_report.py +138 -0
  50. stryx/scanners/__init__.py +17 -0
  51. stryx/scanners/auth.py +444 -0
  52. stryx/scanners/authorization.py +220 -0
  53. stryx/scanners/blind.py +328 -0
  54. stryx/scanners/cloud_ssrf.py +208 -0
  55. stryx/scanners/cors.py +158 -0
  56. stryx/scanners/dependencies.py +296 -0
  57. stryx/scanners/disclosure.py +339 -0
  58. stryx/scanners/fuzz.py +391 -0
  59. stryx/scanners/graphql.py +309 -0
  60. stryx/scanners/injection.py +511 -0
  61. stryx/scanners/race.py +257 -0
  62. stryx/signatures/framework_fingerprints.yaml +122 -0
  63. stryx/signatures/known_vulns.yaml +210 -0
  64. stryx/utils/__init__.py +7 -0
  65. stryx/utils/evidence.py +109 -0
  66. stryx/utils/http_client.py +159 -0
  67. stryx/utils/logging.py +51 -0
  68. stryx/utils/rate_limiter.py +37 -0
  69. stryx_cli-0.1.0.dist-info/METADATA +236 -0
  70. stryx_cli-0.1.0.dist-info/RECORD +74 -0
  71. stryx_cli-0.1.0.dist-info/WHEEL +5 -0
  72. stryx_cli-0.1.0.dist-info/entry_points.txt +2 -0
  73. stryx_cli-0.1.0.dist-info/licenses/LICENSE +190 -0
  74. 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}"