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
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Scan comparison and baseline tracking.
|
|
2
|
+
|
|
3
|
+
Compares current scan results against a previous baseline to detect
|
|
4
|
+
new vulnerabilities, resolved issues, and severity changes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from stryx.utils.logging import get_logger
|
|
16
|
+
|
|
17
|
+
logger = get_logger("comparison.differ")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class FindingDiff:
|
|
22
|
+
"""A single finding change between scans."""
|
|
23
|
+
|
|
24
|
+
title: str
|
|
25
|
+
endpoint: str
|
|
26
|
+
severity: str
|
|
27
|
+
status: str # "new", "resolved", "severity_changed"
|
|
28
|
+
old_severity: str | None = None
|
|
29
|
+
new_severity: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ScanDiff:
|
|
34
|
+
"""Result of comparing two scans."""
|
|
35
|
+
|
|
36
|
+
baseline_file: str
|
|
37
|
+
current_file: str
|
|
38
|
+
baseline_time: str
|
|
39
|
+
current_time: str
|
|
40
|
+
new_findings: list[FindingDiff] = field(default_factory=list)
|
|
41
|
+
resolved_findings: list[FindingDiff] = field(default_factory=list)
|
|
42
|
+
severity_changes: list[FindingDiff] = field(default_factory=list)
|
|
43
|
+
unchanged_count: int = 0
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def has_regressions(self) -> bool:
|
|
47
|
+
"""Check if there are new critical/high findings."""
|
|
48
|
+
return any(
|
|
49
|
+
d.severity in ("critical", "high")
|
|
50
|
+
for d in self.new_findings
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def total_changes(self) -> int:
|
|
55
|
+
return len(self.new_findings) + len(self.resolved_findings) + len(self.severity_changes)
|
|
56
|
+
|
|
57
|
+
def summary(self) -> str:
|
|
58
|
+
"""Generate a human-readable summary."""
|
|
59
|
+
lines = [
|
|
60
|
+
f"📊 Scan Comparison",
|
|
61
|
+
f"Baseline: {self.baseline_file} ({self.baseline_time})",
|
|
62
|
+
f"Current: {self.current_file} ({self.current_time})",
|
|
63
|
+
"",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
if self.new_findings:
|
|
67
|
+
lines.append(f"🆕 New findings: {len(self.new_findings)}")
|
|
68
|
+
for f in self.new_findings[:10]:
|
|
69
|
+
lines.append(f" - [{f.severity.upper()}] {f.title} @ {f.endpoint}")
|
|
70
|
+
if len(self.new_findings) > 10:
|
|
71
|
+
lines.append(f" ... and {len(self.new_findings) - 10} more")
|
|
72
|
+
|
|
73
|
+
if self.resolved_findings:
|
|
74
|
+
lines.append(f"✅ Resolved findings: {len(self.resolved_findings)}")
|
|
75
|
+
for f in self.resolved_findings[:5]:
|
|
76
|
+
lines.append(f" - [{f.severity.upper()}] {f.title}")
|
|
77
|
+
|
|
78
|
+
if self.severity_changes:
|
|
79
|
+
lines.append(f"⚡ Severity changes: {len(self.severity_changes)}")
|
|
80
|
+
for f in self.severity_changes[:5]:
|
|
81
|
+
lines.append(f" - {f.title}: {f.old_severity} → {f.new_severity}")
|
|
82
|
+
|
|
83
|
+
if not self.new_findings and not self.resolved_findings and not self.severity_changes:
|
|
84
|
+
lines.append("✅ No changes detected between scans")
|
|
85
|
+
|
|
86
|
+
if self.has_regressions:
|
|
87
|
+
lines.append("")
|
|
88
|
+
lines.append("⚠️ REGRESSION DETECTED: New critical/high findings!")
|
|
89
|
+
|
|
90
|
+
return "\n".join(lines)
|
|
91
|
+
|
|
92
|
+
def to_dict(self) -> dict[str, Any]:
|
|
93
|
+
"""Convert to dictionary for JSON serialization."""
|
|
94
|
+
return {
|
|
95
|
+
"baseline": {
|
|
96
|
+
"file": self.baseline_file,
|
|
97
|
+
"timestamp": self.baseline_time,
|
|
98
|
+
},
|
|
99
|
+
"current": {
|
|
100
|
+
"file": self.current_file,
|
|
101
|
+
"timestamp": self.current_time,
|
|
102
|
+
},
|
|
103
|
+
"summary": {
|
|
104
|
+
"new_findings": len(self.new_findings),
|
|
105
|
+
"resolved_findings": len(self.resolved_findings),
|
|
106
|
+
"severity_changes": len(self.severity_changes),
|
|
107
|
+
"unchanged": self.unchanged_count,
|
|
108
|
+
"has_regressions": self.has_regressions,
|
|
109
|
+
},
|
|
110
|
+
"new_findings": [
|
|
111
|
+
{"title": d.title, "endpoint": d.endpoint, "severity": d.severity}
|
|
112
|
+
for d in self.new_findings
|
|
113
|
+
],
|
|
114
|
+
"resolved_findings": [
|
|
115
|
+
{"title": d.title, "endpoint": d.endpoint, "severity": d.severity}
|
|
116
|
+
for d in self.resolved_findings
|
|
117
|
+
],
|
|
118
|
+
"severity_changes": [
|
|
119
|
+
{
|
|
120
|
+
"title": d.title,
|
|
121
|
+
"endpoint": d.endpoint,
|
|
122
|
+
"old_severity": d.old_severity,
|
|
123
|
+
"new_severity": d.new_severity,
|
|
124
|
+
}
|
|
125
|
+
for d in self.severity_changes
|
|
126
|
+
],
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _fingerprint_finding(finding: dict) -> str:
|
|
131
|
+
"""Create a unique fingerprint for a finding."""
|
|
132
|
+
title = finding.get("title", "")
|
|
133
|
+
endpoint = finding.get("endpoint", "")
|
|
134
|
+
return f"{title}::{endpoint}"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class ScanDiffer:
|
|
138
|
+
"""Compares two scan JSON files."""
|
|
139
|
+
|
|
140
|
+
def __init__(self, baseline_path: str, current_path: str):
|
|
141
|
+
self.baseline_path = baseline_path
|
|
142
|
+
self.current_path = current_path
|
|
143
|
+
self._baseline: dict[str, Any] = {}
|
|
144
|
+
self._current: dict[str, Any] = {}
|
|
145
|
+
|
|
146
|
+
def load(self) -> bool:
|
|
147
|
+
"""Load both scan files."""
|
|
148
|
+
try:
|
|
149
|
+
with open(self.baseline_path, encoding="utf-8") as f:
|
|
150
|
+
self._baseline = json.load(f)
|
|
151
|
+
with open(self.current_path, encoding="utf-8") as f:
|
|
152
|
+
self._current = json.load(f)
|
|
153
|
+
return True
|
|
154
|
+
except (json.JSONDecodeError, FileNotFoundError) as e:
|
|
155
|
+
logger.error(f"Failed to load scan files: {e}")
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
def compare(self) -> ScanDiff:
|
|
159
|
+
"""Compare the two scans and return the diff."""
|
|
160
|
+
if not self._baseline or not self._current:
|
|
161
|
+
if not self.load():
|
|
162
|
+
return ScanDiff(
|
|
163
|
+
baseline_file=self.baseline_path,
|
|
164
|
+
current_file=self.current_path,
|
|
165
|
+
baseline_time="unknown",
|
|
166
|
+
current_time="unknown",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
baseline_findings = self._index_findings(self._baseline.get("findings", []))
|
|
170
|
+
current_findings = self._index_findings(self._current.get("findings", []))
|
|
171
|
+
|
|
172
|
+
baseline_fps = set(baseline_findings.keys())
|
|
173
|
+
current_fps = set(current_findings.keys())
|
|
174
|
+
|
|
175
|
+
# New findings (in current but not baseline)
|
|
176
|
+
new_fps = current_fps - baseline_fps
|
|
177
|
+
resolved_fps = baseline_fps - current_fps
|
|
178
|
+
common_fps = baseline_fps & current_fps
|
|
179
|
+
|
|
180
|
+
new_diffs = [
|
|
181
|
+
FindingDiff(
|
|
182
|
+
title=current_findings[fp]["title"],
|
|
183
|
+
endpoint=current_findings[fp].get("endpoint", ""),
|
|
184
|
+
severity=current_findings[fp].get("severity", "info"),
|
|
185
|
+
status="new",
|
|
186
|
+
)
|
|
187
|
+
for fp in sorted(new_fps)
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
resolved_diffs = [
|
|
191
|
+
FindingDiff(
|
|
192
|
+
title=baseline_findings[fp]["title"],
|
|
193
|
+
endpoint=baseline_findings[fp].get("endpoint", ""),
|
|
194
|
+
severity=baseline_findings[fp].get("severity", "info"),
|
|
195
|
+
status="resolved",
|
|
196
|
+
)
|
|
197
|
+
for fp in sorted(resolved_fps)
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
# Check for severity changes
|
|
201
|
+
severity_changes = []
|
|
202
|
+
for fp in common_fps:
|
|
203
|
+
old_sev = baseline_findings[fp].get("severity", "info")
|
|
204
|
+
new_sev = current_findings[fp].get("severity", "info")
|
|
205
|
+
if old_sev != new_sev:
|
|
206
|
+
severity_changes.append(FindingDiff(
|
|
207
|
+
title=current_findings[fp]["title"],
|
|
208
|
+
endpoint=current_findings[fp].get("endpoint", ""),
|
|
209
|
+
severity=new_sev,
|
|
210
|
+
status="severity_changed",
|
|
211
|
+
old_severity=old_sev,
|
|
212
|
+
new_severity=new_sev,
|
|
213
|
+
))
|
|
214
|
+
|
|
215
|
+
return ScanDiff(
|
|
216
|
+
baseline_file=self.baseline_path,
|
|
217
|
+
current_file=self.current_path,
|
|
218
|
+
baseline_time=self._baseline.get("timestamp", "unknown"),
|
|
219
|
+
current_time=self._current.get("timestamp", "unknown"),
|
|
220
|
+
new_findings=new_diffs,
|
|
221
|
+
resolved_findings=resolved_diffs,
|
|
222
|
+
severity_changes=severity_changes,
|
|
223
|
+
unchanged_count=len(common_fps) - len(severity_changes),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def _index_findings(
|
|
227
|
+
self, findings: list[dict]
|
|
228
|
+
) -> dict[str, dict]:
|
|
229
|
+
"""Index findings by their fingerprint."""
|
|
230
|
+
indexed = {}
|
|
231
|
+
for f in findings:
|
|
232
|
+
fp = _fingerprint_finding(f)
|
|
233
|
+
indexed[fp] = f
|
|
234
|
+
return indexed
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def save_scan_metadata(
|
|
238
|
+
path: str,
|
|
239
|
+
findings_count: int,
|
|
240
|
+
target_url: str,
|
|
241
|
+
metadata: dict[str, Any] | None = None,
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Save scan metadata alongside the JSON report for future comparison."""
|
|
244
|
+
meta_path = Path(path).with_suffix(".meta.json")
|
|
245
|
+
meta = {
|
|
246
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
247
|
+
"target_url": target_url,
|
|
248
|
+
"findings_count": findings_count,
|
|
249
|
+
"tool_version": "0.1.0",
|
|
250
|
+
}
|
|
251
|
+
if metadata:
|
|
252
|
+
meta.update(metadata)
|
|
253
|
+
|
|
254
|
+
with open(meta_path, "w", encoding="utf-8") as f:
|
|
255
|
+
json.dump(meta, f, indent=2)
|
stryx/config/__init__.py
ADDED
stryx/config/loader.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Configuration loader with priority: CLI flags > file config > defaults."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from stryx.config.schema import StryxConfig
|
|
12
|
+
|
|
13
|
+
_DEFAULT_CONFIG_PATH = Path(__file__).parent / "default_config.yaml"
|
|
14
|
+
_USER_CONFIG_PATH = Path.home() / ".stryx" / "config.yaml"
|
|
15
|
+
_LOCAL_CONFIG_PATH = Path("stryx.config.yaml")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _read_yaml(path: Path) -> dict[str, Any]:
|
|
19
|
+
"""Read and parse a YAML file, returning empty dict on failure."""
|
|
20
|
+
if path.exists() and path.is_file():
|
|
21
|
+
try:
|
|
22
|
+
with open(path) as f:
|
|
23
|
+
data = yaml.safe_load(f)
|
|
24
|
+
return data if isinstance(data, dict) else {}
|
|
25
|
+
except (yaml.YAMLError, OSError):
|
|
26
|
+
return {}
|
|
27
|
+
return {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_config(cli_overrides: dict[str, Any] | None = None) -> StryxConfig:
|
|
31
|
+
"""Load configuration with priority: CLI > user file > local file > defaults.
|
|
32
|
+
|
|
33
|
+
Priority order:
|
|
34
|
+
1. CLI flags (passed as cli_overrides)
|
|
35
|
+
2. ~/.stryx/config.yaml (user-level config)
|
|
36
|
+
3. ./stryx.config.yaml (project-level config)
|
|
37
|
+
4. default_config.yaml (built-in defaults)
|
|
38
|
+
"""
|
|
39
|
+
# Start with defaults
|
|
40
|
+
defaults = _read_yaml(_DEFAULT_CONFIG_PATH)
|
|
41
|
+
|
|
42
|
+
# Layer local project config
|
|
43
|
+
local = _read_yaml(_LOCAL_CONFIG_PATH)
|
|
44
|
+
|
|
45
|
+
# Layer user config
|
|
46
|
+
user = _read_yaml(_USER_CONFIG_PATH)
|
|
47
|
+
|
|
48
|
+
# Merge: defaults < local < user
|
|
49
|
+
merged: dict[str, Any] = defaults
|
|
50
|
+
for key, value in local.items():
|
|
51
|
+
if key == "modules" and isinstance(value, dict) and isinstance(merged.get("modules"), dict):
|
|
52
|
+
merged["modules"].update(value)
|
|
53
|
+
else:
|
|
54
|
+
merged[key] = value
|
|
55
|
+
for key, value in user.items():
|
|
56
|
+
if key == "modules" and isinstance(value, dict) and isinstance(merged.get("modules"), dict):
|
|
57
|
+
merged["modules"].update(value)
|
|
58
|
+
else:
|
|
59
|
+
merged[key] = value
|
|
60
|
+
|
|
61
|
+
# Apply CLI overrides (non-None values only)
|
|
62
|
+
if cli_overrides:
|
|
63
|
+
for key, value in cli_overrides.items():
|
|
64
|
+
if value is not None:
|
|
65
|
+
if key == "modules" and isinstance(value, dict) and isinstance(merged.get("modules"), dict):
|
|
66
|
+
merged["modules"].update(value)
|
|
67
|
+
else:
|
|
68
|
+
merged[key] = value
|
|
69
|
+
|
|
70
|
+
# Read API key from environment if not set
|
|
71
|
+
if merged.get("api_key") is None:
|
|
72
|
+
provider = merged.get("provider", "groq")
|
|
73
|
+
env_key_map = {
|
|
74
|
+
"openai": "OPENAI_API_KEY",
|
|
75
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
76
|
+
"groq": "GROQ_API_KEY",
|
|
77
|
+
"openrouter": "OPENROUTER_API_KEY",
|
|
78
|
+
"nvidia_nim": "NVIDIA_NIM_API_KEY",
|
|
79
|
+
"xai": "XAI_API_KEY",
|
|
80
|
+
}
|
|
81
|
+
env_var = env_key_map.get(provider)
|
|
82
|
+
if env_var:
|
|
83
|
+
merged["api_key"] = os.environ.get(env_var)
|
|
84
|
+
|
|
85
|
+
# Also check Ollama (no key needed for local)
|
|
86
|
+
if merged.get("provider") == "ollama":
|
|
87
|
+
merged["api_key"] = "ollama" # dummy key, not needed
|
|
88
|
+
|
|
89
|
+
return StryxConfig(**merged)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_effective_config(config: StryxConfig) -> dict[str, Any]:
|
|
93
|
+
"""Return the resolved effective config as a dictionary (for display)."""
|
|
94
|
+
return config.model_dump(exclude_none=True)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def save_config(config: StryxConfig, path: Path | None = None) -> Path:
|
|
98
|
+
"""Save configuration to the user config path."""
|
|
99
|
+
target = path or _USER_CONFIG_PATH
|
|
100
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
data = config.model_dump(exclude_none=True)
|
|
102
|
+
# Remove CLI-only fields
|
|
103
|
+
cli_fields = {
|
|
104
|
+
"target_url", "deep", "json_output", "html_output",
|
|
105
|
+
"markdown_output", "headers", "cookies", "proxy", "wordlist", "rate",
|
|
106
|
+
}
|
|
107
|
+
for field in cli_fields:
|
|
108
|
+
data.pop(field, None)
|
|
109
|
+
with open(target, "w") as f:
|
|
110
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
111
|
+
return target
|
stryx/config/schema.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Pydantic schema for STRYX configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ModuleConfig(BaseModel):
|
|
9
|
+
"""Which scanner modules are enabled."""
|
|
10
|
+
|
|
11
|
+
auth: bool = True
|
|
12
|
+
authorization: bool = True
|
|
13
|
+
injection: bool = True
|
|
14
|
+
fuzzing: bool = True
|
|
15
|
+
blind: bool = True
|
|
16
|
+
disclosure: bool = True
|
|
17
|
+
race: bool = True
|
|
18
|
+
cloud_ssrf: bool = True
|
|
19
|
+
dependencies: bool = True
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SessionConfig(BaseModel):
|
|
23
|
+
"""Authentication session configuration."""
|
|
24
|
+
|
|
25
|
+
username: str | None = None
|
|
26
|
+
password: str | None = None
|
|
27
|
+
login_url: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PolicyConfig(BaseModel):
|
|
31
|
+
"""Security policy for CI/CD quality gates."""
|
|
32
|
+
|
|
33
|
+
enabled: bool = False
|
|
34
|
+
policy_file: str | None = None
|
|
35
|
+
max_critical: int | None = None
|
|
36
|
+
max_high: int | None = None
|
|
37
|
+
max_medium: int | None = None
|
|
38
|
+
max_low: int | None = None
|
|
39
|
+
max_findings: int | None = None
|
|
40
|
+
blocked_cwe: list[str] = Field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ComparisonConfig(BaseModel):
|
|
44
|
+
"""Baseline comparison configuration."""
|
|
45
|
+
|
|
46
|
+
enabled: bool = False
|
|
47
|
+
baseline_file: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class StryxConfig(BaseModel):
|
|
51
|
+
"""Root configuration model with validation."""
|
|
52
|
+
|
|
53
|
+
provider: str = Field(default="groq", description="AI provider name")
|
|
54
|
+
model: str = Field(default="openai/gpt-oss-120b", description="AI model identifier")
|
|
55
|
+
api_key: str | None = Field(default=None, description="API key for the AI provider")
|
|
56
|
+
threads: int = Field(default=20, ge=1, le=200, description="Concurrent threads")
|
|
57
|
+
timeout: int = Field(default=10, ge=1, le=120, description="HTTP timeout in seconds")
|
|
58
|
+
crawl_depth: int = Field(default=5, ge=1, le=50, description="Max crawl depth")
|
|
59
|
+
respect_robots: bool = Field(default=False, description="Respect robots.txt rules")
|
|
60
|
+
ai_attack_planning: bool = True
|
|
61
|
+
ai_payload_generation: bool = True
|
|
62
|
+
modules: ModuleConfig = Field(default_factory=ModuleConfig)
|
|
63
|
+
session: SessionConfig = Field(default_factory=SessionConfig)
|
|
64
|
+
policy: PolicyConfig = Field(default_factory=PolicyConfig)
|
|
65
|
+
comparison: ComparisonConfig = Field(default_factory=ComparisonConfig)
|
|
66
|
+
|
|
67
|
+
# CLI overrides (not from config file)
|
|
68
|
+
target_url: str | None = None
|
|
69
|
+
deep: bool = False
|
|
70
|
+
json_output: str | None = None
|
|
71
|
+
html_output: str | None = None
|
|
72
|
+
markdown_output: str | None = None
|
|
73
|
+
sarif_output: str | None = None
|
|
74
|
+
headers: dict[str, str] | None = None
|
|
75
|
+
cookies: str | None = None
|
|
76
|
+
proxy: str | None = None
|
|
77
|
+
wordlist: str | None = None
|
|
78
|
+
rate: int | None = None
|
|
79
|
+
policy_file: str | None = None
|
|
80
|
+
baseline_file: str | None = None
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Main discovery aggregator -- merges results from all crawler modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from stryx.utils.logging import get_logger
|
|
8
|
+
|
|
9
|
+
logger = get_logger("crawler.discovery")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Endpoint:
|
|
14
|
+
"""A discovered endpoint with metadata."""
|
|
15
|
+
|
|
16
|
+
path: str
|
|
17
|
+
method: str = "GET"
|
|
18
|
+
source: str = "unknown"
|
|
19
|
+
confidence: float = 1.0
|
|
20
|
+
params: list[str] = field(default_factory=list)
|
|
21
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
22
|
+
body: str | None = None
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def url(self) -> str:
|
|
26
|
+
"""Full URL if path starts with http, else just the path."""
|
|
27
|
+
return self.path
|
|
28
|
+
|
|
29
|
+
def __hash__(self) -> int:
|
|
30
|
+
return hash((self.path.lower(), self.method.upper()))
|
|
31
|
+
|
|
32
|
+
def __eq__(self, other: object) -> bool:
|
|
33
|
+
if not isinstance(other, Endpoint):
|
|
34
|
+
return NotImplemented
|
|
35
|
+
return self.path.lower() == other.path.lower() and self.method.upper() == other.method.upper()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DiscoveryAggregator:
|
|
39
|
+
"""Aggregates endpoint discovery from all crawler modules.
|
|
40
|
+
|
|
41
|
+
Merges and deduplicates results into a single Endpoint list.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
target_url: str,
|
|
47
|
+
depth: int = 5,
|
|
48
|
+
wordlist: str | None = None,
|
|
49
|
+
respect_robots: bool = False,
|
|
50
|
+
):
|
|
51
|
+
self.target_url = target_url.rstrip("/")
|
|
52
|
+
self.depth = depth
|
|
53
|
+
self.wordlist = wordlist
|
|
54
|
+
self.respect_robots = respect_robots
|
|
55
|
+
self.endpoints: list[Endpoint] = []
|
|
56
|
+
|
|
57
|
+
async def discover(self) -> list[Endpoint]:
|
|
58
|
+
"""Run all discovery modules and return merged endpoints."""
|
|
59
|
+
logger.info(f"Starting endpoint discovery for {self.target_url}")
|
|
60
|
+
|
|
61
|
+
# 1. Run passive discovery modules in parallel (OpenAPI, Sitemap, GraphQL, external JS)
|
|
62
|
+
from stryx.crawler.graphql_discovery import discover_graphql
|
|
63
|
+
from stryx.crawler.js_endpoints import discover_js_endpoints
|
|
64
|
+
from stryx.crawler.openapi import discover_openapi
|
|
65
|
+
from stryx.crawler.sitemap import discover_sitemap
|
|
66
|
+
|
|
67
|
+
passive_tasks = [
|
|
68
|
+
discover_openapi(self.target_url),
|
|
69
|
+
discover_sitemap(self.target_url),
|
|
70
|
+
discover_graphql(self.target_url),
|
|
71
|
+
discover_js_endpoints(self.target_url),
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
import asyncio
|
|
75
|
+
passive_results = await asyncio.gather(*passive_tasks, return_exceptions=True)
|
|
76
|
+
for result in passive_results:
|
|
77
|
+
if isinstance(result, list):
|
|
78
|
+
self.endpoints.extend(result)
|
|
79
|
+
elif isinstance(result, Exception):
|
|
80
|
+
logger.warning(f"Discovery module failed: {result}")
|
|
81
|
+
|
|
82
|
+
# 2. Run recursive HTML crawler (follows links, extracts forms)
|
|
83
|
+
from stryx.crawler.html_crawler import HTMLCrawler
|
|
84
|
+
|
|
85
|
+
crawler = HTMLCrawler(
|
|
86
|
+
self.target_url,
|
|
87
|
+
max_depth=self.depth,
|
|
88
|
+
max_pages=500,
|
|
89
|
+
respect_robots=self.respect_robots,
|
|
90
|
+
)
|
|
91
|
+
try:
|
|
92
|
+
html_endpoints = await crawler.crawl()
|
|
93
|
+
self.endpoints.extend(html_endpoints)
|
|
94
|
+
except Exception as e:
|
|
95
|
+
logger.warning(f"HTML crawler failed: {e}")
|
|
96
|
+
|
|
97
|
+
# 3. Wordlist-based discovery if a wordlist was provided
|
|
98
|
+
if self.wordlist:
|
|
99
|
+
await self._discover_from_wordlist()
|
|
100
|
+
|
|
101
|
+
# Deduplicate
|
|
102
|
+
seen: set[tuple[str, str]] = set()
|
|
103
|
+
unique: list[Endpoint] = []
|
|
104
|
+
for ep in self.endpoints:
|
|
105
|
+
key = (ep.path.lower(), ep.method.upper())
|
|
106
|
+
if key not in seen:
|
|
107
|
+
seen.add(key)
|
|
108
|
+
unique.append(ep)
|
|
109
|
+
self.endpoints = unique
|
|
110
|
+
|
|
111
|
+
self._log_summary()
|
|
112
|
+
return self.endpoints
|
|
113
|
+
|
|
114
|
+
async def _discover_from_wordlist(self) -> None:
|
|
115
|
+
"""Discover endpoints from a wordlist file."""
|
|
116
|
+
from pathlib import Path
|
|
117
|
+
|
|
118
|
+
wordlist_path = Path(self.wordlist)
|
|
119
|
+
if not wordlist_path.exists():
|
|
120
|
+
logger.warning(f"Wordlist not found: {self.wordlist}")
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
logger.info(f"Loading wordlist from {self.wordlist}")
|
|
124
|
+
lines = wordlist_path.read_text().splitlines()
|
|
125
|
+
words = [line.strip() for line in lines if line.strip() and not line.startswith("#")]
|
|
126
|
+
|
|
127
|
+
if not words:
|
|
128
|
+
logger.warning("Wordlist is empty")
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
logger.info(f"Testing {len(words)} paths from wordlist")
|
|
132
|
+
|
|
133
|
+
from stryx.utils.http_client import HttpClient
|
|
134
|
+
client = HttpClient(timeout=5)
|
|
135
|
+
batch_size = 50
|
|
136
|
+
|
|
137
|
+
for i in range(0, len(words), batch_size):
|
|
138
|
+
batch = words[i : i + batch_size]
|
|
139
|
+
tasks = []
|
|
140
|
+
for word in batch:
|
|
141
|
+
url = f"{self.target_url}/{word.lstrip('/')}"
|
|
142
|
+
tasks.append(self._probe_path(client, url, f"wordlist:{word}"))
|
|
143
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
144
|
+
for result in results:
|
|
145
|
+
if isinstance(result, Endpoint):
|
|
146
|
+
self.endpoints.append(result)
|
|
147
|
+
|
|
148
|
+
async def _probe_path(
|
|
149
|
+
self, client, url: str, source: str
|
|
150
|
+
) -> Endpoint | None:
|
|
151
|
+
"""Probe a single path and return an Endpoint if it exists."""
|
|
152
|
+
try:
|
|
153
|
+
response, _ = await client.get(url)
|
|
154
|
+
if response.status_code < 400:
|
|
155
|
+
return Endpoint(
|
|
156
|
+
path=url,
|
|
157
|
+
method="GET",
|
|
158
|
+
source=source,
|
|
159
|
+
confidence=0.7,
|
|
160
|
+
)
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
def _log_summary(self) -> None:
|
|
166
|
+
"""Log endpoint summary statistics."""
|
|
167
|
+
method_counts: dict[str, int] = {}
|
|
168
|
+
source_counts: dict[str, int] = {}
|
|
169
|
+
for ep in self.endpoints:
|
|
170
|
+
method = ep.method.upper()
|
|
171
|
+
method_counts[method] = method_counts.get(method, 0) + 1
|
|
172
|
+
source_counts[ep.source] = source_counts.get(ep.source, 0) + 1
|
|
173
|
+
|
|
174
|
+
logger.info(f"{len(self.endpoints)} Endpoints")
|
|
175
|
+
for method, count in sorted(method_counts.items(), key=lambda x: -x[1]):
|
|
176
|
+
logger.info(f" {count} {method}")
|
|
177
|
+
for source, count in sorted(source_counts.items(), key=lambda x: -x[1]):
|
|
178
|
+
logger.info(f" {count} from {source}")
|