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
@@ -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)
@@ -0,0 +1,6 @@
1
+ """Configuration management for STRYX."""
2
+
3
+ from stryx.config.loader import get_effective_config, load_config
4
+ from stryx.config.schema import StryxConfig
5
+
6
+ __all__ = ["load_config", "get_effective_config", "StryxConfig"]
@@ -0,0 +1,12 @@
1
+ provider: groq
2
+ model: openai/gpt-oss-120b
3
+ threads: 20
4
+ timeout: 10
5
+ crawl_depth: 5
6
+ respect_robots: false
7
+ ai_attack_planning: true
8
+ modules:
9
+ auth: true
10
+ authorization: true
11
+ injection: true
12
+ fuzzing: true
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,5 @@
1
+ """Endpoint discovery and crawling modules for STRYX."""
2
+
3
+ from stryx.crawler.discovery import DiscoveryAggregator, Endpoint
4
+
5
+ __all__ = ["Endpoint", "DiscoveryAggregator"]
@@ -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}")