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/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."""