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/cli.py
ADDED
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
"""STRYX CLI -- command-line interface for Dynamic Application Security Testing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
import yaml
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
|
|
16
|
+
from stryx.config.loader import get_effective_config, load_config, save_config
|
|
17
|
+
from stryx.config.schema import StryxConfig
|
|
18
|
+
from stryx.orchestrator import Orchestrator
|
|
19
|
+
from stryx.utils.http_client import HttpClient
|
|
20
|
+
from stryx.utils.logging import setup_logging
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _first_run_check() -> bool:
|
|
26
|
+
"""Check if this is the first run (no config exists)."""
|
|
27
|
+
config_path = Path.home() / ".stryx" / "config.yaml"
|
|
28
|
+
return not config_path.exists()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _interactive_config() -> StryxConfig:
|
|
32
|
+
"""Interactive configuration wizard for first-time setup."""
|
|
33
|
+
console.print(Panel(
|
|
34
|
+
"[bold cyan]Welcome to STRYX![/]\n\n"
|
|
35
|
+
"This appears to be your first run. Let's configure your AI provider.\n"
|
|
36
|
+
"You can change these settings later with [bold]stryx config[/].",
|
|
37
|
+
title="First-Time Setup",
|
|
38
|
+
border_style="cyan",
|
|
39
|
+
))
|
|
40
|
+
|
|
41
|
+
providers = {
|
|
42
|
+
"1": ("groq", "Groq (free tier available, fast)"),
|
|
43
|
+
"2": ("openai", "OpenAI (GPT-4, GPT-4o)"),
|
|
44
|
+
"3": ("anthropic", "Anthropic (Claude)"),
|
|
45
|
+
"4": ("openrouter", "OpenRouter (multi-model access)"),
|
|
46
|
+
"5": ("ollama", "Ollama (local models, no API key)"),
|
|
47
|
+
"6": ("xai", "XAI / Grok"),
|
|
48
|
+
"7": ("nvidia_nim", "NVIDIA NIM"),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
console.print("\n[bold]Select your AI provider:[/]\n")
|
|
52
|
+
for key, (name, desc) in providers.items():
|
|
53
|
+
console.print(f" {key}. {name} - {desc}")
|
|
54
|
+
|
|
55
|
+
choice = click.prompt("\nChoice", type=str, default="1")
|
|
56
|
+
provider_name = providers.get(choice, ("groq",))[0]
|
|
57
|
+
|
|
58
|
+
api_key = None
|
|
59
|
+
model = None
|
|
60
|
+
if provider_name != "ollama":
|
|
61
|
+
env_key_map = {
|
|
62
|
+
"openai": "OPENAI_API_KEY",
|
|
63
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
64
|
+
"groq": "GROQ_API_KEY",
|
|
65
|
+
"openrouter": "OPENROUTER_API_KEY",
|
|
66
|
+
"nvidia_nim": "NVIDIA_NIM_API_KEY",
|
|
67
|
+
"xai": "XAI_API_KEY",
|
|
68
|
+
}
|
|
69
|
+
env_var = env_key_map.get(provider_name, "")
|
|
70
|
+
existing_key = os.environ.get(env_var) if env_var else None
|
|
71
|
+
|
|
72
|
+
if existing_key:
|
|
73
|
+
console.print(f"[green]Found {env_var} in environment.[/]")
|
|
74
|
+
use_existing = click.confirm("Use this existing API key?", default=True)
|
|
75
|
+
if use_existing:
|
|
76
|
+
api_key = existing_key
|
|
77
|
+
|
|
78
|
+
if not api_key:
|
|
79
|
+
console.print(
|
|
80
|
+
f"\n[dim]You can paste your API key below (Ctrl+V / right-click).[/]\n"
|
|
81
|
+
f"[dim]Or press Enter to skip and set {env_var} later.[/]"
|
|
82
|
+
)
|
|
83
|
+
api_key = click.prompt(
|
|
84
|
+
f"API key for {provider_name}",
|
|
85
|
+
type=str,
|
|
86
|
+
default="",
|
|
87
|
+
)
|
|
88
|
+
if not api_key:
|
|
89
|
+
api_key = None
|
|
90
|
+
console.print(
|
|
91
|
+
f"[yellow]No key provided. Set {env_var} env var before scanning.[/]"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
model_defaults = {
|
|
95
|
+
"groq": "llama-3.3-70b-versatile",
|
|
96
|
+
"openai": "gpt-4o",
|
|
97
|
+
"anthropic": "claude-3-5-sonnet-20241022",
|
|
98
|
+
"openrouter": "meta-llama/llama-3.3-70b-instruct:free",
|
|
99
|
+
"ollama": "llama3.1",
|
|
100
|
+
"xai": "grok-2",
|
|
101
|
+
"nvidia_nim": "meta/llama-3.1-8b-instruct",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
default_model = model_defaults.get(provider_name, "")
|
|
105
|
+
model = click.prompt(
|
|
106
|
+
"Model to use",
|
|
107
|
+
type=str,
|
|
108
|
+
default=default_model,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
config = StryxConfig(
|
|
112
|
+
provider=provider_name,
|
|
113
|
+
model=model,
|
|
114
|
+
api_key=api_key,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
path = save_config(config)
|
|
118
|
+
console.print(f"\n[green]Configuration saved to {path}[/]")
|
|
119
|
+
|
|
120
|
+
return config
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@click.group()
|
|
124
|
+
@click.option("--config", "config_path", type=click.Path(), help="Path to config file")
|
|
125
|
+
@click.pass_context
|
|
126
|
+
def main(ctx: click.Context, config_path: str | None = None) -> None:
|
|
127
|
+
"""STRYX - AI-Powered Dynamic Application Security Testing (DAST)."""
|
|
128
|
+
setup_logging()
|
|
129
|
+
|
|
130
|
+
if _first_run_check() and ctx.invoked_subcommand != "config":
|
|
131
|
+
config = _interactive_config()
|
|
132
|
+
ctx.ensure_object(dict)
|
|
133
|
+
ctx.obj["config"] = config
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
ctx.ensure_object(dict)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@main.command()
|
|
140
|
+
@click.argument("target_url")
|
|
141
|
+
@click.option("--deep", is_flag=True, help="Enable deep scanning (more thorough, slower)")
|
|
142
|
+
@click.option("--json", "json_output", type=click.Path(), help="Output JSON report to file")
|
|
143
|
+
@click.option("--html", "html_output", type=click.Path(), help="Output HTML report to file")
|
|
144
|
+
@click.option("--markdown", "markdown_output", type=click.Path(), help="Output Markdown report to file")
|
|
145
|
+
@click.option("--sarif", "sarif_output", type=click.Path(), help="Output SARIF report for CI/CD")
|
|
146
|
+
@click.option("--threads", type=int, help="Number of concurrent threads (1-200)")
|
|
147
|
+
@click.option("--timeout", type=int, help="HTTP request timeout in seconds (1-120)")
|
|
148
|
+
@click.option("--headers", type=str, help="Custom headers (JSON format)")
|
|
149
|
+
@click.option("--cookies", type=str, help="Cookies string")
|
|
150
|
+
@click.option("--proxy", type=str, help="HTTP proxy URL")
|
|
151
|
+
@click.option("--wordlist", type=click.Path(), help="Custom wordlist for discovery")
|
|
152
|
+
@click.option("--rate", type=int, help="Requests per second rate limit")
|
|
153
|
+
@click.option("--verbose", is_flag=True, help="Enable verbose output")
|
|
154
|
+
@click.option("--policy", "policy_file", type=click.Path(), help="Security policy file (YAML)")
|
|
155
|
+
@click.option("--baseline", "baseline_file", type=click.Path(), help="Previous scan JSON for comparison")
|
|
156
|
+
@click.option("--username", type=str, help="Login username for authenticated scanning")
|
|
157
|
+
@click.option("--password", type=str, help="Login password for authenticated scanning")
|
|
158
|
+
@click.option("--login-url", type=str, help="Login URL for authentication")
|
|
159
|
+
@click.pass_context
|
|
160
|
+
def scan(
|
|
161
|
+
ctx: click.Context,
|
|
162
|
+
target_url: str,
|
|
163
|
+
deep: bool,
|
|
164
|
+
json_output: str | None,
|
|
165
|
+
html_output: str | None,
|
|
166
|
+
markdown_output: str | None,
|
|
167
|
+
sarif_output: str | None,
|
|
168
|
+
threads: int | None,
|
|
169
|
+
timeout: int | None,
|
|
170
|
+
headers: str | None,
|
|
171
|
+
cookies: str | None,
|
|
172
|
+
proxy: str | None,
|
|
173
|
+
wordlist: str | None,
|
|
174
|
+
rate: int | None,
|
|
175
|
+
verbose: bool,
|
|
176
|
+
policy_file: str | None,
|
|
177
|
+
baseline_file: str | None,
|
|
178
|
+
username: str | None,
|
|
179
|
+
password: str | None,
|
|
180
|
+
login_url: str | None,
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Run a full security scan against a target URL."""
|
|
183
|
+
console.print(Panel(
|
|
184
|
+
f"[bold]STRYX Scan[/]\nTarget: {target_url}",
|
|
185
|
+
border_style="cyan",
|
|
186
|
+
))
|
|
187
|
+
|
|
188
|
+
overrides = {
|
|
189
|
+
"target_url": target_url,
|
|
190
|
+
"deep": deep,
|
|
191
|
+
"json_output": json_output,
|
|
192
|
+
"html_output": html_output,
|
|
193
|
+
"markdown_output": markdown_output,
|
|
194
|
+
"sarif_output": sarif_output,
|
|
195
|
+
"threads": threads,
|
|
196
|
+
"timeout": timeout,
|
|
197
|
+
"proxy": proxy,
|
|
198
|
+
"wordlist": wordlist,
|
|
199
|
+
"rate": rate,
|
|
200
|
+
"policy_file": policy_file,
|
|
201
|
+
"baseline_file": baseline_file,
|
|
202
|
+
}
|
|
203
|
+
if headers:
|
|
204
|
+
try:
|
|
205
|
+
overrides["headers"] = json.loads(headers)
|
|
206
|
+
except json.JSONDecodeError:
|
|
207
|
+
console.print("[red]Invalid headers JSON format[/]")
|
|
208
|
+
return
|
|
209
|
+
if cookies:
|
|
210
|
+
overrides["cookies"] = cookies
|
|
211
|
+
|
|
212
|
+
# Session config
|
|
213
|
+
if username or password:
|
|
214
|
+
overrides["session"] = {
|
|
215
|
+
"username": username,
|
|
216
|
+
"password": password,
|
|
217
|
+
"login_url": login_url,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
config = load_config(overrides)
|
|
221
|
+
|
|
222
|
+
if deep:
|
|
223
|
+
if not threads:
|
|
224
|
+
config.threads = 50
|
|
225
|
+
console.print("[cyan]Deep mode enabled -- more thorough scanning[/]")
|
|
226
|
+
|
|
227
|
+
orchestrator = Orchestrator(config)
|
|
228
|
+
findings = asyncio.run(orchestrator.run())
|
|
229
|
+
|
|
230
|
+
# Print scan summary
|
|
231
|
+
console.print()
|
|
232
|
+
severity_counts = {}
|
|
233
|
+
for f in findings:
|
|
234
|
+
sev = f.severity.value
|
|
235
|
+
severity_counts[sev] = severity_counts.get(sev, 0) + 1
|
|
236
|
+
|
|
237
|
+
if findings:
|
|
238
|
+
console.print(Panel(
|
|
239
|
+
f"[bold]Scan Complete[/]\n"
|
|
240
|
+
f"Total findings: {len(findings)}\n"
|
|
241
|
+
f"Critical: {severity_counts.get('critical', 0)} | "
|
|
242
|
+
f"High: {severity_counts.get('high', 0)} | "
|
|
243
|
+
f"Medium: {severity_counts.get('medium', 0)} | "
|
|
244
|
+
f"Low: {severity_counts.get('low', 0)} | "
|
|
245
|
+
f"Info: {severity_counts.get('info', 0)}",
|
|
246
|
+
title="Results Summary",
|
|
247
|
+
border_style="green" if not severity_counts.get("critical") else "red",
|
|
248
|
+
))
|
|
249
|
+
else:
|
|
250
|
+
console.print("[green]No findings -- target appears secure[/]")
|
|
251
|
+
|
|
252
|
+
# Print output file paths
|
|
253
|
+
if json_output:
|
|
254
|
+
console.print(f"[dim]JSON report: {json_output}[/]")
|
|
255
|
+
if html_output:
|
|
256
|
+
console.print(f"[dim]HTML report: {html_output}[/]")
|
|
257
|
+
if markdown_output:
|
|
258
|
+
console.print(f"[dim]Markdown report: {markdown_output}[/]")
|
|
259
|
+
if sarif_output:
|
|
260
|
+
console.print(f"[dim]SARIF report: {sarif_output}[/]")
|
|
261
|
+
|
|
262
|
+
# Policy result
|
|
263
|
+
if hasattr(orchestrator, '_policy_failed') and orchestrator._policy_failed:
|
|
264
|
+
console.print("[red]❌ Policy check FAILED[/]")
|
|
265
|
+
sys.exit(1)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@main.command()
|
|
269
|
+
@click.argument("target_url")
|
|
270
|
+
@click.option("--depth", type=int, default=5, help="Crawl depth")
|
|
271
|
+
@click.option("--json", "json_output", type=click.Path(), help="Output JSON report")
|
|
272
|
+
@click.option("--wordlist", type=click.Path(), help="Custom wordlist for discovery")
|
|
273
|
+
def crawl(target_url: str, depth: int, json_output: str | None, wordlist: str | None) -> None:
|
|
274
|
+
"""Crawl a target and discover endpoints."""
|
|
275
|
+
console.print(Panel(f"[bold]STRYX Crawl[/]\nTarget: {target_url}", border_style="cyan"))
|
|
276
|
+
|
|
277
|
+
from stryx.crawler.discovery import DiscoveryAggregator
|
|
278
|
+
|
|
279
|
+
async def _crawl():
|
|
280
|
+
aggregator = DiscoveryAggregator(target_url, depth, wordlist=wordlist)
|
|
281
|
+
endpoints = await aggregator.discover()
|
|
282
|
+
console.print(f"\n[green]Found {len(endpoints)} endpoints[/]")
|
|
283
|
+
for ep in endpoints:
|
|
284
|
+
console.print(f" {ep.method:6s} {ep.path} (source: {ep.source})")
|
|
285
|
+
|
|
286
|
+
if json_output and endpoints:
|
|
287
|
+
data = [{"method": ep.method, "path": ep.path, "source": ep.source} for ep in endpoints]
|
|
288
|
+
Path(json_output).write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
289
|
+
console.print(f"[dim]Saved to {json_output}[/]")
|
|
290
|
+
|
|
291
|
+
asyncio.run(_crawl())
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@main.command()
|
|
295
|
+
@click.argument("target_url")
|
|
296
|
+
@click.option("--cookies", type=str, help="Authentication cookies")
|
|
297
|
+
@click.option("--headers", type=str, help="Custom headers (JSON format)")
|
|
298
|
+
def auth(target_url: str, cookies: str | None, headers: str | None) -> None:
|
|
299
|
+
"""Run authentication vulnerability tests."""
|
|
300
|
+
console.print(Panel(f"[bold]STRYX Auth Scan[/]\nTarget: {target_url}", border_style="cyan"))
|
|
301
|
+
|
|
302
|
+
from stryx.scanners.auth import AuthScanner
|
|
303
|
+
|
|
304
|
+
async def _auth():
|
|
305
|
+
parsed_headers = {}
|
|
306
|
+
if headers:
|
|
307
|
+
try:
|
|
308
|
+
parsed_headers = json.loads(headers)
|
|
309
|
+
except json.JSONDecodeError:
|
|
310
|
+
console.print("[red]Invalid headers JSON[/]")
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
client = HttpClient(timeout=10, headers=parsed_headers, cookies=cookies)
|
|
314
|
+
scanner = AuthScanner(client)
|
|
315
|
+
findings = await scanner.scan([target_url], target_url)
|
|
316
|
+
|
|
317
|
+
if findings:
|
|
318
|
+
for f in findings:
|
|
319
|
+
console.print(f"[red]{f.severity.value.upper()}[/] {f.title}")
|
|
320
|
+
else:
|
|
321
|
+
console.print("[green]No authentication issues found[/]")
|
|
322
|
+
|
|
323
|
+
asyncio.run(_auth())
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@main.command()
|
|
327
|
+
@click.argument("target_url")
|
|
328
|
+
@click.option("--cookies", type=str, help="Authentication cookies")
|
|
329
|
+
@click.option("--headers", type=str, help="Custom headers (JSON format)")
|
|
330
|
+
def fuzz(target_url: str, cookies: str | None, headers: str | None) -> None:
|
|
331
|
+
"""Run API fuzzing tests."""
|
|
332
|
+
console.print(Panel(f"[bold]STRYX Fuzz[/]\nTarget: {target_url}", border_style="cyan"))
|
|
333
|
+
|
|
334
|
+
from stryx.scanners.fuzz import FuzzScanner
|
|
335
|
+
|
|
336
|
+
async def _fuzz():
|
|
337
|
+
parsed_headers = {}
|
|
338
|
+
if headers:
|
|
339
|
+
try:
|
|
340
|
+
parsed_headers = json.loads(headers)
|
|
341
|
+
except json.JSONDecodeError:
|
|
342
|
+
console.print("[red]Invalid headers JSON[/]")
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
client = HttpClient(timeout=10, headers=parsed_headers, cookies=cookies)
|
|
346
|
+
scanner = FuzzScanner(client)
|
|
347
|
+
findings = await scanner.scan([target_url], target_url)
|
|
348
|
+
|
|
349
|
+
if findings:
|
|
350
|
+
for f in findings:
|
|
351
|
+
console.print(f"[yellow]{f.severity.value.upper()}[/] {f.title}")
|
|
352
|
+
else:
|
|
353
|
+
console.print("[green]No fuzzing issues found[/]")
|
|
354
|
+
|
|
355
|
+
asyncio.run(_fuzz())
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@main.command()
|
|
359
|
+
@click.argument("target_url")
|
|
360
|
+
@click.option("--cookies", type=str, help="Authentication cookies")
|
|
361
|
+
@click.option("--headers", type=str, help="Custom headers (JSON format)")
|
|
362
|
+
def disclosure(target_url: str, cookies: str | None, headers: str | None) -> None:
|
|
363
|
+
"""Run information disclosure tests."""
|
|
364
|
+
console.print(Panel(f"[bold]STRYX Disclosure Scan[/]\nTarget: {target_url}", border_style="cyan"))
|
|
365
|
+
|
|
366
|
+
from stryx.scanners.disclosure import DisclosureScanner
|
|
367
|
+
|
|
368
|
+
async def _disclosure():
|
|
369
|
+
parsed_headers = {}
|
|
370
|
+
if headers:
|
|
371
|
+
try:
|
|
372
|
+
parsed_headers = json.loads(headers)
|
|
373
|
+
except json.JSONDecodeError:
|
|
374
|
+
console.print("[red]Invalid headers JSON[/]")
|
|
375
|
+
return
|
|
376
|
+
|
|
377
|
+
client = HttpClient(timeout=10, headers=parsed_headers, cookies=cookies)
|
|
378
|
+
scanner = DisclosureScanner(client)
|
|
379
|
+
findings = await scanner.scan([target_url], target_url)
|
|
380
|
+
|
|
381
|
+
if findings:
|
|
382
|
+
for f in findings:
|
|
383
|
+
sev_color = {"critical": "red", "high": "red", "medium": "yellow", "low": "blue"}.get(f.severity.value, "white")
|
|
384
|
+
console.print(f"[{sev_color}]{f.severity.value.upper()}[/] {f.title}")
|
|
385
|
+
else:
|
|
386
|
+
console.print("[green]No disclosure issues found[/]")
|
|
387
|
+
|
|
388
|
+
asyncio.run(_disclosure())
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@main.command()
|
|
392
|
+
@click.argument("target_url")
|
|
393
|
+
def blind(target_url: str) -> None:
|
|
394
|
+
"""Run blind injection tests (timing-based)."""
|
|
395
|
+
console.print(Panel(f"[bold]STRYX Blind Injection Scan[/]\nTarget: {target_url}", border_style="cyan"))
|
|
396
|
+
|
|
397
|
+
from stryx.scanners.blind import BlindScanner
|
|
398
|
+
|
|
399
|
+
async def _blind():
|
|
400
|
+
client = HttpClient(timeout=10)
|
|
401
|
+
scanner = BlindScanner(client)
|
|
402
|
+
findings = await scanner.scan([target_url], target_url)
|
|
403
|
+
|
|
404
|
+
if findings:
|
|
405
|
+
for f in findings:
|
|
406
|
+
console.print(f"[red]{f.severity.value.upper()}[/] {f.title}")
|
|
407
|
+
else:
|
|
408
|
+
console.print("[green]No blind injection issues found[/]")
|
|
409
|
+
|
|
410
|
+
asyncio.run(_blind())
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@main.command()
|
|
414
|
+
@click.argument("json_input", type=click.Path(exists=True))
|
|
415
|
+
@click.option("--html", "html_output", type=click.Path(), help="Output HTML report")
|
|
416
|
+
@click.option("--markdown", "markdown_output", type=click.Path(), help="Output Markdown report")
|
|
417
|
+
@click.option("--sarif", "sarif_output", type=click.Path(), help="Output SARIF report")
|
|
418
|
+
@click.option("--target", "target_url", type=str, default="", help="Target URL for report header")
|
|
419
|
+
def report(
|
|
420
|
+
json_input: str,
|
|
421
|
+
html_output: str | None,
|
|
422
|
+
markdown_output: str | None,
|
|
423
|
+
sarif_output: str | None,
|
|
424
|
+
target_url: str,
|
|
425
|
+
) -> None:
|
|
426
|
+
"""Regenerate reports from a JSON scan file."""
|
|
427
|
+
console.print(Panel("[bold]STRYX Report Generator[/]", border_style="cyan"))
|
|
428
|
+
|
|
429
|
+
from stryx.reports.generator import ReportGenerator
|
|
430
|
+
from stryx.utils.evidence import Evidence, Finding, Severity
|
|
431
|
+
|
|
432
|
+
try:
|
|
433
|
+
with open(json_input, encoding="utf-8") as f:
|
|
434
|
+
data = json.load(f)
|
|
435
|
+
except (json.JSONDecodeError, FileNotFoundError) as e:
|
|
436
|
+
console.print(f"[red]Failed to load JSON report: {e}[/]")
|
|
437
|
+
return
|
|
438
|
+
|
|
439
|
+
findings = []
|
|
440
|
+
target = target_url or data.get("target", "")
|
|
441
|
+
|
|
442
|
+
for rf in data.get("findings", []):
|
|
443
|
+
try:
|
|
444
|
+
evidence_data = rf.get("evidence", {})
|
|
445
|
+
evidence = Evidence(
|
|
446
|
+
request_method=evidence_data.get("request_method", "GET"),
|
|
447
|
+
request_url=evidence_data.get("request_url", ""),
|
|
448
|
+
request_headers=evidence_data.get("request_headers", {}),
|
|
449
|
+
request_body=evidence_data.get("request_body"),
|
|
450
|
+
response_status=evidence_data.get("response_status", 0),
|
|
451
|
+
response_headers=evidence_data.get("response_headers", {}),
|
|
452
|
+
response_body=evidence_data.get("response_body", ""),
|
|
453
|
+
response_snippet=evidence_data.get("response_snippet", ""),
|
|
454
|
+
payload=evidence_data.get("payload", ""),
|
|
455
|
+
confidence=evidence_data.get("confidence", 0.5),
|
|
456
|
+
)
|
|
457
|
+
finding = Finding(
|
|
458
|
+
title=rf.get("title", "Unknown"),
|
|
459
|
+
severity=Severity(rf.get("severity", "info")),
|
|
460
|
+
evidence=evidence,
|
|
461
|
+
description=rf.get("description", ""),
|
|
462
|
+
remediation=rf.get("remediation", ""),
|
|
463
|
+
cwe=rf.get("cwe", ""),
|
|
464
|
+
owasp=rf.get("owasp", ""),
|
|
465
|
+
endpoint=rf.get("endpoint", ""),
|
|
466
|
+
scanner=rf.get("scanner", ""),
|
|
467
|
+
)
|
|
468
|
+
findings.append(finding)
|
|
469
|
+
except Exception as e:
|
|
470
|
+
console.print(f"[yellow]Skipping malformed finding: {e}[/]")
|
|
471
|
+
|
|
472
|
+
if not findings:
|
|
473
|
+
console.print("[yellow]No findings found in JSON file[/]")
|
|
474
|
+
return
|
|
475
|
+
|
|
476
|
+
console.print(f"[green]Loaded {len(findings)} findings from {json_input}[/]")
|
|
477
|
+
|
|
478
|
+
generator = ReportGenerator(target, findings)
|
|
479
|
+
|
|
480
|
+
if html_output:
|
|
481
|
+
generator.generate_html(html_output)
|
|
482
|
+
console.print(f"[green]HTML report saved to {html_output}[/]")
|
|
483
|
+
|
|
484
|
+
if markdown_output:
|
|
485
|
+
generator.generate_markdown(markdown_output)
|
|
486
|
+
console.print(f"[green]Markdown report saved to {markdown_output}[/]")
|
|
487
|
+
|
|
488
|
+
if sarif_output:
|
|
489
|
+
from stryx.reports.sarif_report import SarifReport
|
|
490
|
+
sarif = SarifReport(target, findings)
|
|
491
|
+
sarif.save(sarif_output)
|
|
492
|
+
console.print(f"[green]SARIF report saved to {sarif_output}[/]")
|
|
493
|
+
|
|
494
|
+
if not html_output and not markdown_output and not sarif_output:
|
|
495
|
+
base = Path(json_input).stem
|
|
496
|
+
out_dir = Path(json_input).parent
|
|
497
|
+
html_path = str(out_dir / f"{base}.html")
|
|
498
|
+
md_path = str(out_dir / f"{base}.md")
|
|
499
|
+
sarif_path = str(out_dir / f"{base}.sarif")
|
|
500
|
+
generator.generate_html(html_path)
|
|
501
|
+
generator.generate_markdown(md_path)
|
|
502
|
+
sarif = SarifReport(target, findings)
|
|
503
|
+
sarif.save(sarif_path)
|
|
504
|
+
console.print(f"[green]Generated {html_path}, {md_path}, and {sarif_path}[/]")
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
@main.command()
|
|
508
|
+
@click.argument("baseline_file", type=click.Path(exists=True))
|
|
509
|
+
@click.argument("current_file", type=click.Path(exists=True))
|
|
510
|
+
def compare(baseline_file: str, current_file: str) -> None:
|
|
511
|
+
"""Compare two scan results to detect regressions."""
|
|
512
|
+
console.print(Panel("[bold]STRYX Scan Comparison[/]", border_style="cyan"))
|
|
513
|
+
|
|
514
|
+
from stryx.comparison.differ import ScanDiffer
|
|
515
|
+
|
|
516
|
+
differ = ScanDiffer(baseline_file, current_file)
|
|
517
|
+
diff = differ.compare()
|
|
518
|
+
|
|
519
|
+
console.print(diff.summary())
|
|
520
|
+
|
|
521
|
+
if diff.has_regressions:
|
|
522
|
+
console.print("\n[red]⚠️ REGRESSION DETECTED! New critical/high findings found.[/]")
|
|
523
|
+
sys.exit(1)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
@main.group()
|
|
527
|
+
def config() -> None:
|
|
528
|
+
"""View or update STRYX configuration."""
|
|
529
|
+
pass
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
@config.command("show")
|
|
533
|
+
def config_show() -> None:
|
|
534
|
+
"""Show current configuration."""
|
|
535
|
+
console.print(Panel("[bold]STRYX Configuration[/]", border_style="cyan"))
|
|
536
|
+
|
|
537
|
+
current_config = load_config()
|
|
538
|
+
effective = get_effective_config(current_config)
|
|
539
|
+
|
|
540
|
+
console.print("\n[bold]Current configuration:[/]\n")
|
|
541
|
+
console.print(json.dumps(effective, indent=2))
|
|
542
|
+
|
|
543
|
+
from stryx.config.loader import _USER_CONFIG_PATH, _LOCAL_CONFIG_PATH
|
|
544
|
+
console.print(f"\n[dim]User config: {_USER_CONFIG_PATH}[/]")
|
|
545
|
+
console.print(f"[dim]Project config: {_LOCAL_CONFIG_PATH}[/]")
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
@config.command("set")
|
|
549
|
+
@click.argument("key")
|
|
550
|
+
@click.argument("value")
|
|
551
|
+
def config_set(key: str, value: str) -> None:
|
|
552
|
+
"""Set a configuration value.
|
|
553
|
+
|
|
554
|
+
Examples:
|
|
555
|
+
stryx config set provider openai
|
|
556
|
+
stryx config set model gpt-4o
|
|
557
|
+
stryx config set threads 50
|
|
558
|
+
"""
|
|
559
|
+
from stryx.config.loader import _USER_CONFIG_PATH
|
|
560
|
+
|
|
561
|
+
user_config = {}
|
|
562
|
+
if _USER_CONFIG_PATH.exists():
|
|
563
|
+
try:
|
|
564
|
+
with open(_USER_CONFIG_PATH) as f:
|
|
565
|
+
data = yaml.safe_load(f)
|
|
566
|
+
user_config = data if isinstance(data, dict) else {}
|
|
567
|
+
except Exception:
|
|
568
|
+
pass
|
|
569
|
+
|
|
570
|
+
keys = key.split(".")
|
|
571
|
+
target = user_config
|
|
572
|
+
for k in keys[:-1]:
|
|
573
|
+
if k not in target:
|
|
574
|
+
target[k] = {}
|
|
575
|
+
target = target[k]
|
|
576
|
+
|
|
577
|
+
try:
|
|
578
|
+
parsed_value = json.loads(value)
|
|
579
|
+
except (json.JSONDecodeError, TypeError):
|
|
580
|
+
parsed_value = value
|
|
581
|
+
|
|
582
|
+
target[keys[-1]] = parsed_value
|
|
583
|
+
|
|
584
|
+
_USER_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
585
|
+
with open(_USER_CONFIG_PATH, "w", encoding="utf-8") as f:
|
|
586
|
+
yaml.dump(user_config, f, default_flow_style=False, sort_keys=False)
|
|
587
|
+
|
|
588
|
+
console.print(f"[green]Set {key} = {parsed_value}[/]")
|
|
589
|
+
console.print(f"[dim]Saved to {_USER_CONFIG_PATH}[/]")
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
@config.command("get")
|
|
593
|
+
@click.argument("key")
|
|
594
|
+
def config_get(key: str) -> None:
|
|
595
|
+
"""Get a configuration value."""
|
|
596
|
+
current_config = load_config()
|
|
597
|
+
effective = get_effective_config(current_config)
|
|
598
|
+
|
|
599
|
+
keys = key.split(".")
|
|
600
|
+
value = effective
|
|
601
|
+
for k in keys:
|
|
602
|
+
if isinstance(value, dict) and k in value:
|
|
603
|
+
value = value[k]
|
|
604
|
+
else:
|
|
605
|
+
console.print(f"[yellow]Key '{key}' not found in configuration[/]")
|
|
606
|
+
return
|
|
607
|
+
|
|
608
|
+
console.print(f"[bold]{key}[/] = {json.dumps(value, indent=2) if isinstance(value, (dict, list)) else value}")
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
@config.command("reset")
|
|
612
|
+
@click.confirmation_option(prompt="This will reset all configuration to defaults. Continue?")
|
|
613
|
+
def config_reset() -> None:
|
|
614
|
+
"""Reset configuration to defaults."""
|
|
615
|
+
from stryx.config.loader import _USER_CONFIG_PATH
|
|
616
|
+
|
|
617
|
+
if _USER_CONFIG_PATH.exists():
|
|
618
|
+
_USER_CONFIG_PATH.unlink()
|
|
619
|
+
console.print(f"[green]Removed {_USER_CONFIG_PATH}[/]")
|
|
620
|
+
|
|
621
|
+
console.print("[green]Configuration reset to defaults.[/]")
|
|
622
|
+
console.print("[dim]Run 'stryx scan' to trigger the setup wizard.[/]")
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
@main.command()
|
|
626
|
+
def policy() -> None:
|
|
627
|
+
"""Generate a default security policy file."""
|
|
628
|
+
console.print(Panel("[bold]STRYX Policy Generator[/]", border_style="cyan"))
|
|
629
|
+
|
|
630
|
+
from stryx.policy.engine import save_default_policy
|
|
631
|
+
|
|
632
|
+
path = click.prompt("Output path", type=str, default="stryx-policy.yaml")
|
|
633
|
+
save_default_policy(path)
|
|
634
|
+
console.print(f"[green]Default policy saved to {path}[/]")
|
|
635
|
+
console.print("[dim]Edit the file to customize your security policy.[/]")
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
@main.command()
|
|
639
|
+
def providers() -> None:
|
|
640
|
+
"""List supported AI providers."""
|
|
641
|
+
console.print(Panel("[bold]Supported AI Providers[/]", border_style="cyan"))
|
|
642
|
+
|
|
643
|
+
providers_list = [
|
|
644
|
+
("groq", "Groq (free tier, fast inference)", "GROQ_API_KEY"),
|
|
645
|
+
("openai", "OpenAI (GPT-4, GPT-4o)", "OPENAI_API_KEY"),
|
|
646
|
+
("anthropic", "Anthropic (Claude)", "ANTHROPIC_API_KEY"),
|
|
647
|
+
("openrouter", "OpenRouter (multi-model)", "OPENROUTER_API_KEY"),
|
|
648
|
+
("ollama", "Ollama (local, no key needed)", "N/A"),
|
|
649
|
+
("xai", "XAI / Grok", "XAI_API_KEY"),
|
|
650
|
+
("nvidia_nim", "NVIDIA NIM", "NVIDIA_NIM_API_KEY"),
|
|
651
|
+
]
|
|
652
|
+
|
|
653
|
+
for name, desc, env_var in providers_list:
|
|
654
|
+
console.print(f" [bold]{name}[/] - {desc}")
|
|
655
|
+
console.print(f" Env var: {env_var}")
|
|
656
|
+
console.print()
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
@main.command()
|
|
660
|
+
def update() -> None:
|
|
661
|
+
"""Update STRYX to the latest version."""
|
|
662
|
+
console.print(Panel("[bold]STRYX Update[/]", border_style="cyan"))
|
|
663
|
+
console.print("To update STRYX, run:")
|
|
664
|
+
console.print(" pip install --upgrade stryx")
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
@main.command()
|
|
668
|
+
def version() -> None:
|
|
669
|
+
"""Show STRYX version."""
|
|
670
|
+
from stryx import __version__
|
|
671
|
+
console.print(f"STRYX v{__version__}")
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
if __name__ == "__main__":
|
|
675
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Baseline comparison for tracking security posture over time."""
|