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,86 @@
1
+ """GraphQL endpoint discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from stryx.crawler.discovery import Endpoint
8
+ from stryx.utils.http_client import HttpClient
9
+ from stryx.utils.logging import get_logger
10
+
11
+ logger = get_logger("crawler.graphql")
12
+
13
+ GRAPHQL_PATHS = [
14
+ "/graphql",
15
+ "/graphiql",
16
+ "/api/graphql",
17
+ "/query",
18
+ "/api/query",
19
+ "/v1/graphql",
20
+ "/v2/graphql",
21
+ "/gql",
22
+ "/api/gql",
23
+ "/_graphql",
24
+ ]
25
+
26
+
27
+ async def discover_graphql(target_url: str) -> list[Endpoint]:
28
+ """Discover GraphQL endpoints by probing common paths and introspection."""
29
+ logger.info("Scanning for GraphQL endpoints")
30
+ endpoints: list[Endpoint] = []
31
+ client = HttpClient(timeout=5)
32
+
33
+ introspection_query = json.dumps({
34
+ "query": "{ __schema { queryType { name } mutationType { name } types { name } } }"
35
+ })
36
+
37
+ for path in GRAPHQL_PATHS:
38
+ url = f"{target_url}{path}"
39
+ try:
40
+ # Try GET first (some servers support it)
41
+ response, evidence = await client.get(url)
42
+ if response.status_code == 200 and _looks_like_graphql(response.text):
43
+ endpoints.append(Endpoint(
44
+ path=url,
45
+ method="GET",
46
+ source="graphql",
47
+ confidence=0.9,
48
+ ))
49
+ continue
50
+
51
+ # Try POST with introspection query
52
+ response, evidence = await client.post(
53
+ url,
54
+ body=introspection_query,
55
+ headers={"Content-Type": "application/json"},
56
+ )
57
+ if response.status_code == 200:
58
+ try:
59
+ data = response.json()
60
+ if isinstance(data, dict) and "data" in data:
61
+ logger.info(f"Found GraphQL endpoint at {path} (introspection works)")
62
+ endpoints.append(Endpoint(
63
+ path=url,
64
+ method="POST",
65
+ source="graphql-introspection",
66
+ confidence=0.95,
67
+ ))
68
+ except (json.JSONDecodeError, ValueError):
69
+ pass
70
+ except Exception:
71
+ continue
72
+
73
+ return endpoints
74
+
75
+
76
+ def _looks_like_graphql(text: str) -> bool:
77
+ """Check if a response looks like a GraphQL endpoint."""
78
+ indicators = [
79
+ "graphiql",
80
+ "graphql",
81
+ "__schema",
82
+ "query",
83
+ "mutation",
84
+ ]
85
+ text_lower = text.lower()
86
+ return any(ind in text_lower for ind in indicators)
@@ -0,0 +1,294 @@
1
+ """Recursive HTML web crawler with depth-limited link following.
2
+
3
+ Follows <a href>, <form action>, <img src>, <script src>, <link href>,
4
+ and <iframe src> to discover endpoints recursively.
5
+ Respects crawl_depth and deduplicates URLs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from urllib.parse import urljoin, urlparse, urldefrag
12
+
13
+ from stryx.crawler.discovery import Endpoint
14
+ from stryx.utils.http_client import HttpClient
15
+ from stryx.utils.logging import get_logger
16
+
17
+ logger = get_logger("crawler.html")
18
+
19
+ # Patterns to extract URLs from HTML
20
+ _LINK_PATTERNS = [
21
+ # <a href="...">
22
+ re.compile(r'<a\s+[^>]*href=["\']([^"\']+)["\']', re.I),
23
+ # <form action="...">
24
+ re.compile(r'<form\s+[^>]*action=["\']([^"\']+)["\']', re.I),
25
+ # <img src="...">
26
+ re.compile(r'<img\s+[^>]*src=["\']([^"\']+)["\']', re.I),
27
+ # <script src="...">
28
+ re.compile(r'<script\s+[^>]*src=["\']([^"\']+)["\']', re.I),
29
+ # <link href="...">
30
+ re.compile(r'<link\s+[^>]*href=["\']([^"\']+)["\']', re.I),
31
+ # <iframe src="...">
32
+ re.compile(r'<iframe\s+[^>]*src=["\']([^"\']+)["\']', re.I),
33
+ ]
34
+
35
+ # Patterns to extract form inputs
36
+ _FORM_INPUT_PATTERN = re.compile(
37
+ r'<input\s+[^>]*name=["\']([^"\']+)["\']', re.I
38
+ )
39
+ _FORM_SELECT_PATTERN = re.compile(
40
+ r'<select\s+[^>]*name=["\']([^"\']+)["\']', re.I
41
+ )
42
+ _FORM_TEXTAREA_PATTERN = re.compile(
43
+ r'<textarea\s+[^>]*name=["\']([^"\']+)["\']', re.I
44
+ )
45
+
46
+ # Patterns to extract API-like paths from JavaScript
47
+ _JS_API_PATTERNS = [
48
+ re.compile(r"""(?:fetch|axios|\.get|\.post|\.put|\.delete|\.patch)\s*\(\s*["']([^"']+)["']"""),
49
+ re.compile(r"""(?:url|endpoint|path|api)\s*[:=]\s*["']([^"']+)["']"""),
50
+ re.compile(r"""["'](/api/[^"']+)["']"""),
51
+ re.compile(r"""["'](/v[12]/[^"']+)["']"""),
52
+ ]
53
+
54
+ # File extensions to skip (not HTML)
55
+ _SKIP_EXTENSIONS = {
56
+ '.css', '.js', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico',
57
+ '.woff', '.woff2', '.ttf', '.eot', '.map', '.mp4', '.webm',
58
+ '.pdf', '.zip', '.tar', '.gz',
59
+ }
60
+
61
+ # Common ignored paths
62
+ _IGNORED_PATHS = {
63
+ '/favicon.ico', '/robots.txt', '/sitemap.xml',
64
+ '/apple-touch-icon.png', '/manifest.json',
65
+ }
66
+
67
+
68
+ class HTMLCrawler:
69
+ """Recursive HTML crawler that follows links to discover endpoints."""
70
+
71
+ def __init__(
72
+ self,
73
+ target_url: str,
74
+ max_depth: int = 5,
75
+ max_pages: int = 500,
76
+ respect_robots: bool = False,
77
+ ):
78
+ self.target_url = target_url.rstrip("/")
79
+ self.base_domain = urlparse(self.target_url).netloc
80
+ self.max_depth = max_depth
81
+ self.max_pages = max_pages
82
+ self.respect_robots = respect_robots
83
+ self.client = HttpClient(timeout=10)
84
+ self.visited: set[str] = set()
85
+ self.endpoints: list[Endpoint] = []
86
+ self._robots_paths: set[str] = set()
87
+
88
+ async def crawl(self) -> list[Endpoint]:
89
+ """Start recursive crawling from the target URL."""
90
+ logger.info(f"Starting recursive crawl from {self.target_url} (depth={self.max_depth})")
91
+
92
+ if self.respect_robots:
93
+ await self._load_robots_txt()
94
+
95
+ await self._crawl_page(self.target_url, depth=0)
96
+
97
+ logger.info(f"Crawl complete: {len(self.endpoints)} endpoints discovered from {len(self.visited)} pages")
98
+ return self.endpoints
99
+
100
+ async def _crawl_page(self, url: str, depth: int) -> None:
101
+ """Crawl a single page and follow links."""
102
+ # Normalize URL
103
+ url = self._normalize_url(url)
104
+
105
+ # Check limits
106
+ if depth > self.max_depth:
107
+ return
108
+ if len(self.visited) >= self.max_pages:
109
+ return
110
+ if url in self.visited:
111
+ return
112
+
113
+ # Check if same domain
114
+ parsed = urlparse(url)
115
+ if parsed.netloc and parsed.netloc != self.base_domain:
116
+ return
117
+
118
+ # Check robots.txt
119
+ if self.respect_robots and self._is_disallowed(parsed.path):
120
+ return
121
+
122
+ # Skip non-HTML resources
123
+ path_lower = parsed.path.lower()
124
+ if any(path_lower.endswith(ext) for ext in _SKIP_EXTENSIONS):
125
+ return
126
+ if parsed.path in _IGNORED_PATHS:
127
+ return
128
+
129
+ self.visited.add(url)
130
+ logger.debug(f"Crawling [{depth}]: {url}")
131
+
132
+ try:
133
+ response, evidence = await self.client.get(url)
134
+
135
+ if response.status_code >= 400:
136
+ return
137
+
138
+ content_type = response.headers.get("content-type", "").lower()
139
+ if "text/html" not in content_type and "application/xhtml" not in content_type:
140
+ return
141
+
142
+ body = response.text
143
+
144
+ # Register this URL as an endpoint
145
+ self.endpoints.append(Endpoint(
146
+ path=url,
147
+ method="GET",
148
+ source="html_crawler",
149
+ confidence=0.9,
150
+ ))
151
+
152
+ # Extract and follow links
153
+ links = self._extract_links(body, url)
154
+ for link in links:
155
+ await self._crawl_page(link, depth + 1)
156
+
157
+ # Extract form actions and inputs
158
+ forms = self._extract_forms(body, url)
159
+ for form_url, params in forms:
160
+ self.endpoints.append(Endpoint(
161
+ path=form_url,
162
+ method="POST",
163
+ source="html_form",
164
+ confidence=0.85,
165
+ params=params,
166
+ ))
167
+
168
+ # Extract API endpoints from inline JavaScript
169
+ js_endpoints = self._extract_js_endpoints(body, url)
170
+ for js_url in js_endpoints:
171
+ self.endpoints.append(Endpoint(
172
+ path=js_url,
173
+ method="GET",
174
+ source="html_js_inline",
175
+ confidence=0.7,
176
+ ))
177
+
178
+ except Exception as e:
179
+ logger.debug(f"Failed to crawl {url}: {e}")
180
+
181
+ def _extract_links(self, html: str, base_url: str) -> list[str]:
182
+ """Extract all links from HTML content."""
183
+ links = set()
184
+ for pattern in _LINK_PATTERNS:
185
+ for match in pattern.finditer(html):
186
+ href = match.group(1).strip()
187
+ if not href or href.startswith(("#", "javascript:", "mailto:", "tel:")):
188
+ continue
189
+ full_url = self._resolve_url(href, base_url)
190
+ if full_url:
191
+ links.add(full_url)
192
+ return list(links)
193
+
194
+ def _extract_forms(self, html: str, base_url: str) -> list[tuple[str, list[str]]]:
195
+ """Extract form actions and input parameter names."""
196
+ forms = []
197
+ # Find all <form> blocks
198
+ form_pattern = re.compile(r'<form\s+[^>]*action=["\']([^"\']+)["\'][^>]*>(.*?)</form>', re.I | re.S)
199
+ for match in form_pattern.finditer(html):
200
+ action = match.group(1).strip()
201
+ form_body = match.group(2)
202
+ full_url = self._resolve_url(action, base_url)
203
+ if not full_url:
204
+ continue
205
+
206
+ # Extract input names
207
+ params = []
208
+ for p in _FORM_INPUT_PATTERN.finditer(form_body):
209
+ params.append(p.group(1))
210
+ for p in _FORM_SELECT_PATTERN.finditer(form_body):
211
+ params.append(p.group(1))
212
+ for p in _FORM_TEXTAREA_PATTERN.finditer(form_body):
213
+ params.append(p.group(1))
214
+
215
+ forms.append((full_url, params))
216
+
217
+ return forms
218
+
219
+ def _extract_js_endpoints(self, html: str, base_url: str) -> list[str]:
220
+ """Extract API endpoints from inline JavaScript."""
221
+ endpoints = set()
222
+ # Find <script> blocks with inline code
223
+ script_pattern = re.compile(r'<script[^>]*>(.*?)</script>', re.I | re.S)
224
+ for match in script_pattern.finditer(html):
225
+ script_content = match.group(1)
226
+ if not script_content.strip():
227
+ continue
228
+ for pattern in _JS_API_PATTERNS:
229
+ for api_match in pattern.finditer(script_content):
230
+ path = api_match.group(1)
231
+ if path.startswith("/"):
232
+ full_url = f"{self.base_domain}{path}"
233
+ endpoints.add(full_url)
234
+ return list(endpoints)
235
+
236
+ def _resolve_url(self, href: str, base_url: str) -> str | None:
237
+ """Resolve a relative URL to absolute and normalize."""
238
+ try:
239
+ if href.startswith(("http://", "https://")):
240
+ full_url = href
241
+ else:
242
+ full_url = urljoin(base_url, href)
243
+
244
+ # Remove fragment
245
+ full_url, _ = urldefrag(full_url)
246
+
247
+ # Normalize
248
+ full_url = self._normalize_url(full_url)
249
+
250
+ return full_url
251
+ except Exception:
252
+ return None
253
+
254
+ def _normalize_url(self, url: str) -> str:
255
+ """Normalize a URL for deduplication."""
256
+ parsed = urlparse(url)
257
+ # Lowercase scheme and host
258
+ scheme = parsed.scheme.lower()
259
+ netloc = parsed.netloc.lower()
260
+ # Remove default ports
261
+ if netloc.endswith(":80") and scheme == "http":
262
+ netloc = netloc[:-3]
263
+ elif netloc.endswith(":443") and scheme == "https":
264
+ netloc = netloc[:-4]
265
+ # Normalize path
266
+ path = parsed.path.rstrip("/") or "/"
267
+ # Rebuild
268
+ normalized = f"{scheme}://{netloc}{path}"
269
+ if parsed.query:
270
+ normalized += f"?{parsed.query}"
271
+ return normalized
272
+
273
+ async def _load_robots_txt(self) -> None:
274
+ """Load and parse robots.txt Disallow rules."""
275
+ try:
276
+ robots_url = f"{self.base_domain}/robots.txt"
277
+ response, _ = await self.client.get(robots_url)
278
+ if response.status_code == 200:
279
+ for line in response.text.splitlines():
280
+ line = line.strip()
281
+ if line.lower().startswith("disallow:"):
282
+ path = line.split(":", 1)[1].strip()
283
+ if path:
284
+ self._robots_paths.add(path)
285
+ logger.info(f"Loaded {len(self._robots_paths)} disallowed paths from robots.txt")
286
+ except Exception:
287
+ pass
288
+
289
+ def _is_disallowed(self, path: str) -> bool:
290
+ """Check if a path is disallowed by robots.txt."""
291
+ for disallowed in self._robots_paths:
292
+ if path.startswith(disallowed):
293
+ return True
294
+ return False
@@ -0,0 +1,165 @@
1
+ """JavaScript endpoint extraction.
2
+
3
+ Downloads and analyzes JavaScript files to discover API endpoints,
4
+ HTTP methods, and URL patterns. Handles bundled/minified JS.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from urllib.parse import urljoin, urlparse
11
+
12
+ from stryx.crawler.discovery import Endpoint
13
+ from stryx.utils.http_client import HttpClient
14
+ from stryx.utils.logging import get_logger
15
+
16
+ logger = get_logger("crawler.js_endpoints")
17
+
18
+ # Patterns that commonly indicate API endpoints in JS
19
+ ENDPOINT_PATTERNS = [
20
+ # fetch/axios/http client URLs
21
+ re.compile(r'''(?:fetch|axios|\.get|\.post|\.put|\.delete|\.patch|\.request)\s*\(\s*['"`]([^'"`]+)['"`]'''),
22
+ # URL assignments
23
+ re.compile(r'''(?:url|endpoint|api|path|href|baseUrl|baseURL)\s*[=:]\s*['"`]([^'"`]+)['"`]'''),
24
+ # Template literal API calls
25
+ re.compile(r'''`(/api/[^`]+)`'''),
26
+ re.compile(r'''`(/v\d/[^`]+)`'''),
27
+ re.compile(r'''`(/auth/[^`]+)`'''),
28
+ re.compile(r'''`(/admin/[^`]+)`'''),
29
+ # String concatenation API paths
30
+ re.compile(r'''['"]([^'"]*api[^'"]*)['"]'''),
31
+ re.compile(r'''['"]([^'"]*\/users\/[^'"]*)['"]'''),
32
+ re.compile(r'''['"]([^'"]*\/admin[^'"]*)['"]'''),
33
+ re.compile(r'''['"]([^'"]*\/login[^'"]*)['"]'''),
34
+ re.compile(r'''['"]([^'"]*\/auth[^'"]*)['"]'''),
35
+ re.compile(r'''['"]([^'"]*\/register[^'"]*)['"]'''),
36
+ re.compile(r'''['"]([^'"]*\/profile[^'"]*)['"]'''),
37
+ re.compile(r'''['"]([^'"]*\/settings[^'"]*)['"]'''),
38
+ re.compile(r'''['"]([^'"]*\/upload[^'"]*)['"]'''),
39
+ re.compile(r'''['"]([^'"]*\/search[^'"]*)['"]'''),
40
+ re.compile(r'''['"]([^'"]*\/graphql[^'"]*)['"]'''),
41
+ re.compile(r'''['"]([^'"]*\/websocket[^'"]*)['"]'''),
42
+ re.compile(r'''['"]([^'"]*\/ws[^'"]*)['"]'''),
43
+ # HTTP method detection
44
+ re.compile(r'''method\s*:\s*['"]?(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)['"]?'''),
45
+ # Common SPA routes
46
+ re.compile(r'''(?:path|route)\s*:\s*['"]\/([a-zA-Z][a-zA-Z0-9_\/-]*)['"]'''),
47
+ ]
48
+
49
+ # HTTP method patterns (to detect which methods are used)
50
+ METHOD_PATTERNS = {
51
+ "GET": re.compile(r'''\.get\s*\('''),
52
+ "POST": re.compile(r'''\.post\s*\('''),
53
+ "PUT": re.compile(r'''\.put\s*\('''),
54
+ "DELETE": re.compile(r'''\.delete\s*\('''),
55
+ "PATCH": re.compile(r'''\.patch\s*\('''),
56
+ }
57
+
58
+ # Patterns to skip (not endpoints)
59
+ SKIP_PATTERNS = [
60
+ re.compile(r'''node_modules'''),
61
+ re.compile(r'''\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot|map)$'''),
62
+ re.compile(r'''webpack'''),
63
+ re.compile(r'''bundle'''),
64
+ re.compile(r'''chunk'''),
65
+ re.compile(r'''favicon'''),
66
+ re.compile(r'''\.min\.''' ),
67
+ re.compile(r'''^(https?:)?//[a-z0-9.-]+\.(com|org|net|io)/$'''), # Just a domain
68
+ ]
69
+
70
+
71
+ async def discover_js_endpoints(target_url: str) -> list[Endpoint]:
72
+ """Discover endpoints embedded in JavaScript files."""
73
+ logger.info("Scanning JavaScript files for embedded endpoints")
74
+ endpoints: list[Endpoint] = []
75
+ client = HttpClient(timeout=10)
76
+ seen_urls: set[str] = set()
77
+
78
+ # First, try to find JS files from the page
79
+ try:
80
+ response, _ = await client.get(target_url)
81
+ if response.status_code == 200:
82
+ js_files = _extract_js_files(response.text, target_url)
83
+ logger.info(f"Found {len(js_files)} JS files to analyze")
84
+
85
+ for js_url in js_files[:20]: # Limit to 20 files
86
+ try:
87
+ js_response, _ = await client.get(js_url)
88
+ if js_response.status_code == 200:
89
+ found = _extract_endpoints_from_js(js_response.text, target_url)
90
+ for ep in found:
91
+ if ep.path not in seen_urls:
92
+ seen_urls.add(ep.path)
93
+ endpoints.append(ep)
94
+ except Exception:
95
+ continue
96
+ except Exception as e:
97
+ logger.warning(f"Failed to scan JS files: {e}")
98
+
99
+ logger.info(f"Extracted {len(endpoints)} endpoints from JS files")
100
+ return endpoints
101
+
102
+
103
+ def _extract_js_files(html: str, base_url: str) -> list[str]:
104
+ """Extract JavaScript file URLs from HTML."""
105
+ # Match script tags with src attribute
106
+ src_pattern = r'<script[^>]+src=["\']([^"\']+)["\']'
107
+ matches = re.findall(src_pattern, html, re.IGNORECASE)
108
+ return [urljoin(base_url, src) for src in matches]
109
+
110
+
111
+ def _extract_endpoints_from_js(js_content: str, base_url: str) -> list[Endpoint]:
112
+ """Extract API endpoints from JavaScript source code."""
113
+ endpoints: list[Endpoint] = []
114
+ seen: set[str] = set()
115
+
116
+ # Detect HTTP methods used in this JS file
117
+ detected_methods: dict[str, str] = {}
118
+ for method, pattern in METHOD_PATTERNS.items():
119
+ if pattern.search(js_content):
120
+ detected_methods[method.lower()] = method
121
+
122
+ for pattern in ENDPOINT_PATTERNS:
123
+ matches = pattern.findall(js_content)
124
+ for match in matches:
125
+ # Skip very short or generic matches
126
+ if len(match) < 3 or match in seen:
127
+ continue
128
+ seen.add(match)
129
+
130
+ # Skip common non-endpoint strings
131
+ if any(re.search(skip, match, re.I) for skip in SKIP_PATTERNS):
132
+ continue
133
+
134
+ # Skip data URIs, blob URLs, etc.
135
+ if match.startswith(("data:", "blob:", "javascript:", "mailto:", "tel:")):
136
+ continue
137
+
138
+ # Determine if this is an absolute or relative URL
139
+ if match.startswith("http"):
140
+ url = match
141
+ elif match.startswith("/"):
142
+ url = f"{base_url.rstrip('/')}{match}"
143
+ else:
144
+ continue # Skip relative paths without /
145
+
146
+ # Determine likely HTTP method from context
147
+ method = "GET"
148
+ # Look at surrounding context for method hints
149
+ for http_method, m_pattern in METHOD_PATTERNS.items():
150
+ # Check if this URL appears near a method call
151
+ context_start = max(0, js_content.find(match) - 100)
152
+ context_end = min(len(js_content), js_content.find(match) + len(match) + 100)
153
+ context = js_content[context_start:context_end]
154
+ if m_pattern.search(context):
155
+ method = http_method
156
+ break
157
+
158
+ endpoints.append(Endpoint(
159
+ path=url,
160
+ method=method,
161
+ source="js-endpoints",
162
+ confidence=0.65,
163
+ ))
164
+
165
+ return endpoints
@@ -0,0 +1,76 @@
1
+ """OpenAPI/Swagger endpoint discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from stryx.crawler.discovery import Endpoint
6
+ from stryx.utils.http_client import HttpClient
7
+ from stryx.utils.logging import get_logger
8
+
9
+ logger = get_logger("crawler.openapi")
10
+
11
+ OPENAPI_PATHS = [
12
+ "/openapi.json",
13
+ "/swagger.json",
14
+ "/api-docs",
15
+ "/swagger/v1/swagger.json",
16
+ "/v1/openapi.json",
17
+ "/v2/openapi.json",
18
+ "/v3/openapi.json",
19
+ "/api/openapi.json",
20
+ "/docs/openapi.json",
21
+ "/swagger.json",
22
+ "/swagger-ui.json",
23
+ "/api-docs.json",
24
+ "/api/swagger.json",
25
+ "/api/v1/swagger.json",
26
+ "/api/v2/swagger.json",
27
+ ]
28
+
29
+
30
+ async def discover_openapi(target_url: str) -> list[Endpoint]:
31
+ """Discover endpoints from OpenAPI/Swagger documentation."""
32
+ logger.info("Scanning for OpenAPI/Swagger documentation")
33
+ endpoints: list[Endpoint] = []
34
+ client = HttpClient(timeout=5)
35
+
36
+ for path in OPENAPI_PATHS:
37
+ url = f"{target_url}{path}"
38
+ try:
39
+ response, evidence = await client.get(url)
40
+ if response.status_code == 200:
41
+ data = response.json()
42
+ if isinstance(data, dict) and ("paths" in data or "openapi" in data or "swagger" in data):
43
+ logger.info(f"Found OpenAPI spec at {path}")
44
+ endpoints.extend(_parse_openapi_paths(data, target_url))
45
+ except Exception:
46
+ continue
47
+
48
+ return endpoints
49
+
50
+
51
+ def _parse_openapi_paths(spec: dict, base_url: str) -> list[Endpoint]:
52
+ """Parse paths from an OpenAPI specification."""
53
+ endpoints: list[Endpoint] = []
54
+ paths = spec.get("paths", {})
55
+
56
+ for path, methods in paths.items():
57
+ if not isinstance(methods, dict):
58
+ continue
59
+ for method in methods:
60
+ if method.lower() in ("get", "post", "put", "delete", "patch", "head", "options"):
61
+ params = []
62
+ operation = methods[method]
63
+ if isinstance(operation, dict):
64
+ for param in operation.get("parameters", []):
65
+ if isinstance(param, dict):
66
+ params.append(param.get("name", ""))
67
+
68
+ endpoints.append(Endpoint(
69
+ path=f"{base_url}{path}",
70
+ method=method.upper(),
71
+ source="openapi",
72
+ confidence=0.95,
73
+ params=[p for p in params if p],
74
+ ))
75
+
76
+ return endpoints