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
stryx/ai/providers.py ADDED
@@ -0,0 +1,249 @@
1
+ """Unified AI provider abstraction.
2
+
3
+ Supports: OpenAI, Groq, Anthropic, OpenRouter, Ollama, XAI, NVIDIA NIM.
4
+ Normalizes all providers behind a single `generate` interface.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from stryx.utils.logging import get_logger
12
+
13
+ logger = get_logger("ai.providers")
14
+
15
+
16
+ class AIProvider:
17
+ """Base class for AI providers."""
18
+
19
+ def __init__(self, api_key: str | None = None, model: str | None = None):
20
+ self.api_key = api_key
21
+ self.model = model
22
+
23
+ async def generate(self, prompt: str, system_prompt: str = "") -> str:
24
+ """Generate a response from the AI model."""
25
+ raise NotImplementedError
26
+
27
+
28
+ class OpenAIProvider(AIProvider):
29
+ """OpenAI API provider."""
30
+
31
+ async def generate(self, prompt: str, system_prompt: str = "") -> str:
32
+ try:
33
+ from openai import AsyncOpenAI
34
+ client = AsyncOpenAI(api_key=self.api_key)
35
+ messages = []
36
+ if system_prompt:
37
+ messages.append({"role": "system", "content": system_prompt})
38
+ messages.append({"role": "user", "content": prompt})
39
+
40
+ response = await client.chat.completions.create(
41
+ model=self.model or "gpt-4",
42
+ messages=messages,
43
+ temperature=0.1,
44
+ max_tokens=4096,
45
+ )
46
+ return response.choices[0].message.content or ""
47
+ except Exception as e:
48
+ logger.error(f"OpenAI provider error: {e}")
49
+ return ""
50
+
51
+
52
+ class GroqProvider(AIProvider):
53
+ """Groq API provider."""
54
+
55
+ async def generate(self, prompt: str, system_prompt: str = "") -> str:
56
+ try:
57
+ from groq import AsyncGroq
58
+ client = AsyncGroq(api_key=self.api_key)
59
+ messages = []
60
+ if system_prompt:
61
+ messages.append({"role": "system", "content": system_prompt})
62
+ messages.append({"role": "user", "content": prompt})
63
+
64
+ response = await client.chat.completions.create(
65
+ model=self.model or "llama-3.3-70b-versatile",
66
+ messages=messages,
67
+ temperature=0.1,
68
+ max_tokens=4096,
69
+ )
70
+ return response.choices[0].message.content or ""
71
+ except Exception as e:
72
+ logger.error(f"Groq provider error: {e}")
73
+ return ""
74
+
75
+
76
+ class AnthropicProvider(AIProvider):
77
+ """Anthropic API provider."""
78
+
79
+ async def generate(self, prompt: str, system_prompt: str = "") -> str:
80
+ try:
81
+ from anthropic import AsyncAnthropic
82
+ client = AsyncAnthropic(api_key=self.api_key)
83
+ response = await client.messages.create(
84
+ model=self.model or "claude-3-5-sonnet-20241022",
85
+ max_tokens=4096,
86
+ system=system_prompt if system_prompt else "You are a security expert.",
87
+ messages=[{"role": "user", "content": prompt}],
88
+ )
89
+ return response.content[0].text if response.content else ""
90
+ except Exception as e:
91
+ logger.error(f"Anthropic provider error: {e}")
92
+ return ""
93
+
94
+
95
+ class OpenRouterProvider(AIProvider):
96
+ """OpenRouter API provider (routes to multiple models)."""
97
+
98
+ async def generate(self, prompt: str, system_prompt: str = "") -> str:
99
+ try:
100
+ import httpx
101
+ async with httpx.AsyncClient() as client:
102
+ messages = []
103
+ if system_prompt:
104
+ messages.append({"role": "system", "content": system_prompt})
105
+ messages.append({"role": "user", "content": prompt})
106
+
107
+ response = await client.post(
108
+ "https://openrouter.ai/api/v1/chat/completions",
109
+ headers={
110
+ "Authorization": f"Bearer {self.api_key}",
111
+ "Content-Type": "application/json",
112
+ },
113
+ json={
114
+ "model": self.model or "meta-llama/llama-3.3-70b-instruct:free",
115
+ "messages": messages,
116
+ "temperature": 0.1,
117
+ "max_tokens": 4096,
118
+ },
119
+ timeout=60,
120
+ )
121
+ data = response.json()
122
+ return data["choices"][0]["message"]["content"]
123
+ except Exception as e:
124
+ logger.error(f"OpenRouter provider error: {e}")
125
+ return ""
126
+
127
+
128
+ class OllamaProvider(AIProvider):
129
+ """Ollama local model provider."""
130
+
131
+ def __init__(self, api_key: str | None = None, model: str | None = None,
132
+ base_url: str = "http://localhost:11434"):
133
+ super().__init__(api_key, model)
134
+ self.base_url = base_url
135
+
136
+ async def generate(self, prompt: str, system_prompt: str = "") -> str:
137
+ try:
138
+ import httpx
139
+ async with httpx.AsyncClient() as client:
140
+ messages = []
141
+ if system_prompt:
142
+ messages.append({"role": "system", "content": system_prompt})
143
+ messages.append({"role": "user", "content": prompt})
144
+
145
+ response = await client.post(
146
+ f"{self.base_url}/api/chat",
147
+ json={
148
+ "model": self.model or "llama3.1",
149
+ "messages": messages,
150
+ "stream": False,
151
+ },
152
+ timeout=120,
153
+ )
154
+ data = response.json()
155
+ return data.get("message", {}).get("content", "")
156
+ except Exception as e:
157
+ logger.error(f"Ollama provider error: {e}")
158
+ return ""
159
+
160
+
161
+ class XAIProvider(AIProvider):
162
+ """XAI (Grok) API provider."""
163
+
164
+ async def generate(self, prompt: str, system_prompt: str = "") -> str:
165
+ try:
166
+ import httpx
167
+ async with httpx.AsyncClient() as client:
168
+ messages = []
169
+ if system_prompt:
170
+ messages.append({"role": "system", "content": system_prompt})
171
+ messages.append({"role": "user", "content": prompt})
172
+
173
+ response = await client.post(
174
+ "https://api.x.ai/v1/chat/completions",
175
+ headers={
176
+ "Authorization": f"Bearer {self.api_key}",
177
+ "Content-Type": "application/json",
178
+ },
179
+ json={
180
+ "model": self.model or "grok-2",
181
+ "messages": messages,
182
+ "temperature": 0.1,
183
+ "max_tokens": 4096,
184
+ },
185
+ timeout=60,
186
+ )
187
+ data = response.json()
188
+ return data["choices"][0]["message"]["content"]
189
+ except Exception as e:
190
+ logger.error(f"XAI provider error: {e}")
191
+ return ""
192
+
193
+
194
+ class NVIDIANimProvider(AIProvider):
195
+ """NVIDIA NIM API provider."""
196
+
197
+ async def generate(self, prompt: str, system_prompt: str = "") -> str:
198
+ try:
199
+ import httpx
200
+ async with httpx.AsyncClient() as client:
201
+ messages = []
202
+ if system_prompt:
203
+ messages.append({"role": "system", "content": system_prompt})
204
+ messages.append({"role": "user", "content": prompt})
205
+
206
+ response = await client.post(
207
+ "https://integrate.api.nvidia.com/v1/chat/completions",
208
+ headers={
209
+ "Authorization": f"Bearer {self.api_key}",
210
+ "Content-Type": "application/json",
211
+ },
212
+ json={
213
+ "model": self.model or "meta/llama-3.1-8b-instruct",
214
+ "messages": messages,
215
+ "temperature": 0.1,
216
+ "max_tokens": 4096,
217
+ },
218
+ timeout=60,
219
+ )
220
+ data = response.json()
221
+ return data["choices"][0]["message"]["content"]
222
+ except Exception as e:
223
+ logger.error(f"NVIDIA NIM provider error: {e}")
224
+ return ""
225
+
226
+
227
+ def get_provider(name: str, api_key: str | None = None,
228
+ model: str | None = None, **kwargs: Any) -> AIProvider:
229
+ """Factory function to get an AI provider by name."""
230
+ providers = {
231
+ "openai": OpenAIProvider,
232
+ "groq": GroqProvider,
233
+ "anthropic": AnthropicProvider,
234
+ "openrouter": OpenRouterProvider,
235
+ "ollama": OllamaProvider,
236
+ "xai": XAIProvider,
237
+ "nvidia_nim": NVIDIANimProvider,
238
+ "nvidia": NVIDIANimProvider,
239
+ }
240
+
241
+ provider_class = providers.get(name.lower())
242
+ if not provider_class:
243
+ logger.warning(f"Unknown provider '{name}', falling back to Groq")
244
+ provider_class = GroqProvider
245
+
246
+ if name.lower() == "ollama":
247
+ return OllamaProvider(api_key=api_key, model=model, **kwargs)
248
+
249
+ return provider_class(api_key=api_key, model=model)
@@ -0,0 +1,6 @@
1
+ """Attack chaining and replay modules for STRYX."""
2
+
3
+ from stryx.attacks.attack_chain import AttackChain
4
+ from stryx.attacks.replay import ReplayEngine
5
+
6
+ __all__ = ["AttackChain", "ReplayEngine"]
@@ -0,0 +1,111 @@
1
+ """Attack chain builder -- constructs multi-step attack paths from findings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from stryx.utils.evidence import Finding
8
+ from stryx.utils.logging import get_logger
9
+
10
+ logger = get_logger("attacks.chain")
11
+
12
+
13
+ @dataclass
14
+ class AttackStep:
15
+ """A single step in an attack chain."""
16
+
17
+ finding: Finding
18
+ description: str = ""
19
+ prerequisites: list[str] = field(default_factory=list)
20
+ impact: str = ""
21
+
22
+
23
+ @dataclass
24
+ class AttackChain:
25
+ """A multi-step attack path constructed from individual findings."""
26
+
27
+ name: str
28
+ steps: list[AttackStep] = field(default_factory=list)
29
+ total_impact: str = ""
30
+ estimated_severity: str = "high"
31
+
32
+ def add_step(self, finding: Finding, description: str = "", impact: str = "") -> None:
33
+ """Add a step to the attack chain."""
34
+ self.steps.append(AttackStep(
35
+ finding=finding,
36
+ description=description or finding.description,
37
+ impact=impact,
38
+ ))
39
+
40
+ def to_dict(self) -> dict:
41
+ """Convert to dictionary for serialization."""
42
+ return {
43
+ "name": self.name,
44
+ "steps": [
45
+ {
46
+ "title": step.finding.title,
47
+ "description": step.description,
48
+ "severity": step.finding.severity.value,
49
+ "impact": step.impact,
50
+ "endpoint": step.finding.endpoint,
51
+ }
52
+ for step in self.steps
53
+ ],
54
+ "total_impact": self.total_impact,
55
+ "estimated_severity": self.estimated_severity,
56
+ }
57
+
58
+
59
+ class ChainBuilder:
60
+ """Builds attack chains from individual findings."""
61
+
62
+ def __init__(self, findings: list[Finding]):
63
+ self.findings = findings
64
+
65
+ def build_chains(self) -> list[AttackChain]:
66
+ """Analyze findings and construct attack chains."""
67
+ chains: list[AttackChain] = []
68
+
69
+ # Group findings by type
70
+ auth_findings = [f for f in self.findings if "auth" in f.tags]
71
+ injection_findings = [f for f in self.findings if "injection" in f.tags]
72
+ access_findings = [
73
+ f for f in self.findings
74
+ if "idor" in f.tags or "broken-access-control" in f.tags
75
+ ]
76
+ ssrf_findings = [f for f in self.findings if "ssrf" in f.tags]
77
+ traversal_findings = [f for f in self.findings if "path_traversal" in f.tags]
78
+
79
+ # Chain: Auth bypass -> IDOR -> Data exfiltration
80
+ if auth_findings and access_findings:
81
+ chain = AttackChain(name="Authentication Bypass to Data Exfiltration")
82
+ chain.add_step(auth_findings[0], "Bypass authentication", "Gain unauthorized access")
83
+ chain.add_step(access_findings[0], "Exploit IDOR", "Access other users' data")
84
+ chain.total_impact = "Unauthorized access to sensitive user data"
85
+ chain.estimated_severity = "critical"
86
+ chains.append(chain)
87
+
88
+ # Chain: Injection -> Command execution
89
+ if injection_findings:
90
+ chain = AttackChain(name="Injection to Remote Code Execution")
91
+ for f in injection_findings[:2]:
92
+ chain.add_step(f, f.title, "Potential code execution")
93
+ chain.total_impact = "Remote code execution on the server"
94
+ chain.estimated_severity = "critical"
95
+ chains.append(chain)
96
+
97
+ # Chain: SSRF -> Internal access
98
+ if ssrf_findings:
99
+ chain = AttackChain(name="SSRF to Internal Network Access")
100
+ chain.add_step(ssrf_findings[0], "Trigger SSRF", "Access internal services")
101
+ if traversal_findings:
102
+ chain.add_step(
103
+ traversal_findings[0], "Read sensitive files",
104
+ "Access configuration files",
105
+ )
106
+ chain.total_impact = "Access to internal network and sensitive files"
107
+ chain.estimated_severity = "high"
108
+ chains.append(chain)
109
+
110
+ logger.info(f"Built {len(chains)} attack chains from {len(self.findings)} findings")
111
+ return chains
@@ -0,0 +1,186 @@
1
+ """Request replay engine with identity substitution for authorization testing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from stryx.utils.evidence import Finding, Severity
8
+ from stryx.utils.http_client import HttpClient
9
+ from stryx.utils.logging import get_logger
10
+
11
+ logger = get_logger("attacks.replay")
12
+
13
+
14
+ class ReplayEngine:
15
+ """Replays HTTP requests with modified identities for authorization testing."""
16
+
17
+ def __init__(self, client: HttpClient):
18
+ self.client = client
19
+
20
+ async def test_idor(
21
+ self,
22
+ url: str,
23
+ method: str = "GET",
24
+ original_id: str = "1",
25
+ test_ids: list[str] | None = None,
26
+ headers: dict[str, str] | None = None,
27
+ body: str | None = None,
28
+ ) -> Finding | None:
29
+ """Test for IDOR by replaying requests with different IDs.
30
+
31
+ Correctly substitutes IDs in URL paths and query parameters.
32
+ """
33
+ if test_ids is None:
34
+ test_ids = ["2", "3", "4", "5", "100", "999", "0", "-1"]
35
+
36
+ # Get baseline response with original ID
37
+ try:
38
+ baseline_response, baseline_evidence = await self.client.request(
39
+ method=method,
40
+ url=url,
41
+ headers=headers,
42
+ body=body,
43
+ )
44
+ baseline_status = baseline_response.status_code
45
+ baseline_length = len(baseline_response.text)
46
+ except Exception:
47
+ return None
48
+
49
+ if baseline_status != 200:
50
+ return None
51
+
52
+ # Test with different IDs
53
+ for test_id in test_ids:
54
+ test_url = self._substitute_id(url, original_id, test_id)
55
+ if test_url == url:
56
+ continue
57
+
58
+ try:
59
+ response, evidence = await self.client.request(
60
+ method=method,
61
+ url=test_url,
62
+ headers=headers,
63
+ body=body,
64
+ )
65
+
66
+ # If we get same-status response with different ID, it's likely IDOR
67
+ if (response.status_code == baseline_status and
68
+ response.status_code == 200 and
69
+ abs(len(response.text) - baseline_length) < 100):
70
+
71
+ evidence.confidence = 0.7
72
+ evidence.payload = f"ID substitution: {original_id} -> {test_id}"
73
+ return Finding(
74
+ title=f"IDOR: ID substitution {original_id} -> {test_id}",
75
+ severity=Severity.HIGH,
76
+ evidence=evidence,
77
+ description=(
78
+ f"Changing the resource ID from {original_id} to {test_id} "
79
+ f"returned the same response, indicating Insecure Direct "
80
+ f"Object Reference."
81
+ ),
82
+ remediation="Implement proper authorization checks for resource access.",
83
+ cwe="CWE-639",
84
+ owasp="A01:2021 - Broken Access Control",
85
+ scanner="authorization",
86
+ tags=["idor", "replay"],
87
+ )
88
+ except Exception:
89
+ continue
90
+
91
+ return None
92
+
93
+ async def test_horizontal_escalation(
94
+ self,
95
+ url: str,
96
+ user_ids: list[str],
97
+ headers: dict[str, str] | None = None,
98
+ ) -> Finding | None:
99
+ """Test horizontal privilege escalation by accessing other users' resources."""
100
+ if len(user_ids) < 2:
101
+ return None
102
+
103
+ # Access resources as user 1
104
+ user1_url = self._substitute_id(url, "{user_id}", user_ids[0])
105
+ try:
106
+ response1, _ = await self.client.get(user1_url, headers=headers)
107
+ if response1.status_code != 200:
108
+ return None
109
+ except Exception:
110
+ return None
111
+
112
+ # Try to access user 1's resources as user 2
113
+ for other_id in user_ids[1:]:
114
+ other_url = self._substitute_id(url, "{user_id}", other_id)
115
+ try:
116
+ response, evidence = await self.client.get(other_url, headers=headers)
117
+ if response.status_code == 200:
118
+ evidence.confidence = 0.6
119
+ evidence.payload = (
120
+ f"User {other_id} accessing {user_ids[0]}'s resource"
121
+ )
122
+ return Finding(
123
+ title=(
124
+ f"Horizontal privilege escalation: user {other_id} "
125
+ f"accessed user {user_ids[0]}'s resource"
126
+ ),
127
+ severity=Severity.HIGH,
128
+ evidence=evidence,
129
+ description=(
130
+ f"User {other_id} was able to access resources "
131
+ f"belonging to user {user_ids[0]}."
132
+ ),
133
+ remediation="Verify resource ownership before granting access.",
134
+ cwe="CWE-639",
135
+ owasp="A01:2021 - Broken Access Control",
136
+ scanner="authorization",
137
+ tags=["horizontal-escalation", "replay"],
138
+ )
139
+ except Exception:
140
+ continue
141
+
142
+ return None
143
+
144
+ @staticmethod
145
+ def _substitute_id(url: str, old_id: str, new_id: str) -> str:
146
+ """Substitute an ID in a URL path or query parameter."""
147
+ # Replace in path segments: /users/1/ -> /users/2/
148
+ # Replace in query params: ?id=1& -> ?id=2&
149
+ # Be careful not to replace partial matches
150
+ result = url
151
+
152
+ # Try path replacement: /{old_id} or /{old_id}/
153
+ path_pattern = rf"/{re.escape(old_id)}(?=/|$|\?)"
154
+ result = re.sub(path_pattern, f"/{new_id}", result)
155
+
156
+ # Try query parameter replacement: {old_id}& or {old_id}$
157
+ query_pattern = rf"(?<=[?&=]){re.escape(old_id)}(?=[&]|$)"
158
+ result = re.sub(query_pattern, new_id, result)
159
+
160
+ return result
161
+
162
+ @staticmethod
163
+ def extract_ids_from_url(url: str) -> list[str]:
164
+ """Extract numeric IDs from a URL path."""
165
+ ids = []
166
+ # Match numeric segments in path
167
+ for match in re.finditer(r"/(\d+)(?:/|$|\?)", url):
168
+ ids.append(match.group(1))
169
+ # Match numeric query parameters
170
+ for match in re.finditer(r"[?&](\w+)=(\d+)", url):
171
+ ids.append(match.group(2))
172
+ return ids
173
+
174
+ @staticmethod
175
+ def is_idor_candidate(url: str) -> bool:
176
+ """Check if a URL likely contains an ID parameter."""
177
+ # Check for numeric path segments
178
+ if re.search(r"/\d+(?:/|$|\?)", url):
179
+ return True
180
+ # Check for numeric query parameters
181
+ if re.search(r"[?&]\w+=\d+(?:&|$)", url):
182
+ return True
183
+ # Check for common ID parameter names
184
+ if re.search(r"[?&](id|user_id|userId|account_id|item_id|order_id)=", url):
185
+ return True
186
+ return False
stryx/auth/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Authentication and session management for STRYX."""