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,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
|
stryx/crawler/openapi.py
ADDED
|
@@ -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
|