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
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,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
|
stryx/attacks/replay.py
ADDED
|
@@ -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."""
|