shadowcat 2.0.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 (95) hide show
  1. agent/__init__.py +17 -0
  2. agent/benchmark/__init__.py +11 -0
  3. agent/benchmark/cli.py +179 -0
  4. agent/benchmark/config.py +15 -0
  5. agent/benchmark/docker.py +192 -0
  6. agent/benchmark/registry.py +99 -0
  7. agent/core/__init__.py +0 -0
  8. agent/core/agent.py +362 -0
  9. agent/core/backend.py +1667 -0
  10. agent/core/config.py +106 -0
  11. agent/core/controller.py +638 -0
  12. agent/core/events.py +177 -0
  13. agent/core/langfuse.py +320 -0
  14. agent/core/phantom.py +2327 -0
  15. agent/core/planner.py +493 -0
  16. agent/core/profiling.py +58 -0
  17. agent/core/sanitizer.py +104 -0
  18. agent/core/session.py +228 -0
  19. agent/core/tracer.py +137 -0
  20. agent/interface/__init__.py +0 -0
  21. agent/interface/components/__init__.py +0 -0
  22. agent/interface/components/activity_feed.py +202 -0
  23. agent/interface/components/renderers.py +149 -0
  24. agent/interface/components/splash.py +112 -0
  25. agent/interface/main.py +1126 -0
  26. agent/interface/styles.tcss +421 -0
  27. agent/interface/tui.py +508 -0
  28. agent/parsing/html_distiller.py +230 -0
  29. agent/parsing/tool_parser.py +115 -0
  30. agent/prompts/__init__.py +0 -0
  31. agent/prompts/pentesting.py +238 -0
  32. agent/rag_module/knowledge_base.py +116 -0
  33. agent/tests/benchmark_phantom.py +455 -0
  34. agent/tools/__init__.py +14 -0
  35. agent/tools/base.py +99 -0
  36. agent/tools/executor.py +345 -0
  37. agent/tools/registry.py +47 -0
  38. backend/README.md +244 -0
  39. backend/__init__.py +10 -0
  40. backend/api/__init__.py +1 -0
  41. backend/api/routes_scan.py +932 -0
  42. backend/authz.py +75 -0
  43. backend/cli.py +261 -0
  44. backend/compliance/__init__.py +1 -0
  45. backend/compliance/pdpa_mapping.py +16 -0
  46. backend/core/__init__.py +1 -0
  47. backend/core/coverage.py +93 -0
  48. backend/core/events.py +166 -0
  49. backend/core/llm_client.py +614 -0
  50. backend/core/orchestrator.py +402 -0
  51. backend/core/scan_memory.py +236 -0
  52. backend/crawler/__init__.py +1 -0
  53. backend/crawler/csrf.py +75 -0
  54. backend/crawler/headless.py +232 -0
  55. backend/crawler/js_analyzer.py +133 -0
  56. backend/crawler/spider.py +550 -0
  57. backend/crawler/subdomains.py +279 -0
  58. backend/daemon.py +252 -0
  59. backend/db/README.md +86 -0
  60. backend/db/__init__.py +17 -0
  61. backend/db/engine.py +87 -0
  62. backend/db/models.py +206 -0
  63. backend/db/repository.py +316 -0
  64. backend/db/schema.sql +247 -0
  65. backend/modes/__init__.py +5 -0
  66. backend/modes/base.py +178 -0
  67. backend/modes/ctf.py +39 -0
  68. backend/modes/enterprise.py +210 -0
  69. backend/modes/general.py +226 -0
  70. backend/modes/registry.py +34 -0
  71. backend/reporting/__init__.py +0 -0
  72. backend/reporting/generator.py +285 -0
  73. backend/schemas/__init__.py +1 -0
  74. backend/schemas/api.py +423 -0
  75. backend/tools/__init__.py +62 -0
  76. backend/tools/dirbrute_tool.py +304 -0
  77. backend/tools/general_report_tool.py +135 -0
  78. backend/tools/http_tool.py +351 -0
  79. backend/tools/nuclei_tool.py +20 -0
  80. backend/tools/report_tool.py +145 -0
  81. backend/tools/shell_tools.py +23 -0
  82. backend/verification/__init__.py +1 -0
  83. backend/verification/evidence_store.py +125 -0
  84. backend/verification/general_oracle.py +369 -0
  85. backend/verification/idor_oracle.py +131 -0
  86. backend/waf/__init__.py +0 -0
  87. backend/waf/detector.py +147 -0
  88. backend/waf/evasion.py +117 -0
  89. backend/webui/index.html +713 -0
  90. backend/workspace.py +182 -0
  91. shadowcat-2.0.0.dist-info/METADATA +360 -0
  92. shadowcat-2.0.0.dist-info/RECORD +95 -0
  93. shadowcat-2.0.0.dist-info/WHEEL +4 -0
  94. shadowcat-2.0.0.dist-info/entry_points.txt +4 -0
  95. shadowcat-2.0.0.dist-info/licenses/LICENSE.md +21 -0
agent/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """ShadowCat - AI-Powered Penetration Testing Assistant."""
2
+
3
+ __version__ = "1.0.0"
4
+ __author__ = "Your Name"
5
+ __license__ = "MIT"
6
+
7
+ from agent.core.agent import run_pentest
8
+ from agent.core.config import ShadowCatConfig, load_config
9
+ from agent.core.tracer import Tracer, get_global_tracer
10
+
11
+ __all__ = [
12
+ "ShadowCatConfig",
13
+ "Tracer",
14
+ "get_global_tracer",
15
+ "load_config",
16
+ "run_pentest",
17
+ ]
@@ -0,0 +1,11 @@
1
+ """Simple benchmark manager for ShadowCat.
2
+
3
+ Start/stop benchmark containers and expose ports for manual testing.
4
+ """
5
+
6
+ from agent.benchmark.registry import BenchmarkInfo, BenchmarkRegistry
7
+
8
+ __all__ = [
9
+ "BenchmarkInfo",
10
+ "BenchmarkRegistry",
11
+ ]
agent/benchmark/cli.py ADDED
@@ -0,0 +1,179 @@
1
+ """Simple CLI for benchmark management.
2
+
3
+ Usage:
4
+ shadowcat_agent-benchmark list [--tags TAG ...] [--levels N ...]
5
+ shadowcat_agent-benchmark start BENCHMARK_ID
6
+ shadowcat_agent-benchmark stop BENCHMARK_ID
7
+ shadowcat_agent-benchmark status
8
+ """
9
+
10
+ import argparse
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from agent.benchmark.config import DEFAULT_BENCHMARKS_DIR
15
+ from agent.benchmark.docker import (
16
+ get_running_benchmarks,
17
+ start_benchmark,
18
+ stop_benchmark,
19
+ )
20
+ from agent.benchmark.registry import BenchmarkRegistry
21
+
22
+
23
+ def cmd_list(args: argparse.Namespace) -> int:
24
+ """List available benchmarks."""
25
+ benchmarks_dir = Path(args.benchmarks_dir) if args.benchmarks_dir else DEFAULT_BENCHMARKS_DIR
26
+
27
+ try:
28
+ registry = BenchmarkRegistry(benchmarks_dir)
29
+ registry.load()
30
+ except FileNotFoundError as e:
31
+ print(f"Error: {e}")
32
+ return 1
33
+
34
+ # Show tags if requested
35
+ if args.show_tags:
36
+ print("Available tags:")
37
+ for tag in sorted(registry.get_all_tags()):
38
+ count = len(registry.filter(tags=[tag]))
39
+ print(f" {tag}: {count} benchmarks")
40
+ return 0
41
+
42
+ # Filter and list
43
+ benchmarks = registry.filter(tags=args.tags, levels=args.levels)
44
+
45
+ print(f"{'ID':<15} {'Level':^5} {'Name':<40} {'Tags'}")
46
+ print("-" * 80)
47
+ for b in benchmarks:
48
+ tags_str = ", ".join(b.tags[:3])
49
+ if len(b.tags) > 3:
50
+ tags_str += "..."
51
+ print(f"{b.id:<15} {b.level:^5} {b.name[:40]:<40} {tags_str}")
52
+
53
+ print(f"\nTotal: {len(benchmarks)} benchmarks")
54
+ return 0
55
+
56
+
57
+ def cmd_start(args: argparse.Namespace) -> int:
58
+ """Start a benchmark."""
59
+ benchmarks_dir = Path(args.benchmarks_dir) if args.benchmarks_dir else DEFAULT_BENCHMARKS_DIR
60
+
61
+ registry = BenchmarkRegistry(benchmarks_dir)
62
+ benchmark = registry.get(args.benchmark_id)
63
+
64
+ if not benchmark:
65
+ print(f"Error: Benchmark '{args.benchmark_id}' not found")
66
+ return 1
67
+
68
+ print(f"Starting benchmark: {benchmark.name}")
69
+ print(f"Description: {benchmark.description}")
70
+ print(f"Level: {benchmark.level}, Tags: {', '.join(benchmark.tags)}")
71
+ print()
72
+
73
+ result = start_benchmark(benchmark.path)
74
+
75
+ if result["success"]:
76
+ target_url = result["target_url"]
77
+ # Extract port from URL for Docker target
78
+ port = target_url.split(":")[-1]
79
+ docker_url = f"http://host.docker.internal:{port}"
80
+
81
+ print("\nBenchmark started successfully!")
82
+ print(f"Target URL: {target_url}")
83
+ print()
84
+ print("Run ShadowCat against this target:")
85
+ print(f" Local: shadowcat_agent --target {target_url}")
86
+ print(f" Docker: shadowcat_agent --target {docker_url}")
87
+ else:
88
+ print(f"\nFailed to start benchmark: {result['message']}")
89
+ return 1
90
+
91
+ return 0
92
+
93
+
94
+ def cmd_stop(args: argparse.Namespace) -> int:
95
+ """Stop a benchmark."""
96
+ benchmarks_dir = Path(args.benchmarks_dir) if args.benchmarks_dir else DEFAULT_BENCHMARKS_DIR
97
+
98
+ registry = BenchmarkRegistry(benchmarks_dir)
99
+ benchmark = registry.get(args.benchmark_id)
100
+
101
+ if not benchmark:
102
+ print(f"Error: Benchmark '{args.benchmark_id}' not found")
103
+ return 1
104
+
105
+ result = stop_benchmark(benchmark.path)
106
+
107
+ if result["success"]:
108
+ print("Benchmark stopped successfully")
109
+ else:
110
+ print(f"Failed to stop benchmark: {result['message']}")
111
+ return 1
112
+
113
+ return 0
114
+
115
+
116
+ def cmd_status(args: argparse.Namespace) -> int:
117
+ """Show running benchmarks."""
118
+ running = get_running_benchmarks()
119
+
120
+ if not running:
121
+ print("No benchmark containers currently running")
122
+ return 0
123
+
124
+ print("Running benchmark containers:")
125
+ print(f"{'Name':<40} {'Ports':<30} {'Status'}")
126
+ print("-" * 80)
127
+ for container in running:
128
+ print(f"{container['name']:<40} {container['ports']:<30} {container['status']}")
129
+
130
+ return 0
131
+
132
+
133
+ def main() -> None:
134
+ """Main entry point."""
135
+ parser = argparse.ArgumentParser(
136
+ prog="shadowcat_agent-benchmark",
137
+ description="Manage benchmark containers for ShadowCat testing",
138
+ )
139
+ parser.add_argument(
140
+ "--benchmarks-dir",
141
+ "-d",
142
+ help="Path to benchmarks directory",
143
+ )
144
+
145
+ subparsers = parser.add_subparsers(dest="command", required=True)
146
+
147
+ # List command
148
+ list_parser = subparsers.add_parser("list", help="List available benchmarks")
149
+ list_parser.add_argument("--tags", "-t", nargs="+", help="Filter by tags")
150
+ list_parser.add_argument(
151
+ "--levels", "-l", nargs="+", type=int, help="Filter by levels (1, 2, 3)"
152
+ )
153
+ list_parser.add_argument("--show-tags", action="store_true", help="Show all tags")
154
+
155
+ # Start command
156
+ start_parser = subparsers.add_parser("start", help="Start a benchmark")
157
+ start_parser.add_argument("benchmark_id", help="Benchmark ID (e.g., XBEN-001-24)")
158
+
159
+ # Stop command
160
+ stop_parser = subparsers.add_parser("stop", help="Stop a benchmark")
161
+ stop_parser.add_argument("benchmark_id", help="Benchmark ID")
162
+
163
+ # Status command
164
+ subparsers.add_parser("status", help="Show running benchmarks")
165
+
166
+ args = parser.parse_args()
167
+
168
+ if args.command == "list":
169
+ sys.exit(cmd_list(args))
170
+ elif args.command == "start":
171
+ sys.exit(cmd_start(args))
172
+ elif args.command == "stop":
173
+ sys.exit(cmd_stop(args))
174
+ elif args.command == "status":
175
+ sys.exit(cmd_status(args))
176
+
177
+
178
+ if __name__ == "__main__":
179
+ main()
@@ -0,0 +1,15 @@
1
+ """Simple configuration for benchmark manager."""
2
+
3
+ from pathlib import Path
4
+
5
+ # Default benchmarks directory (relative to project root)
6
+ # Use xbow-validation-benchmarks as the default benchmark suite
7
+ DEFAULT_BENCHMARKS_DIR = (
8
+ Path(__file__).parent.parent.parent / "benchmark" / "xbow-validation-benchmarks" / "benchmarks"
9
+ )
10
+
11
+ # Default host to bind ports
12
+ DEFAULT_HOST = "0.0.0.0"
13
+
14
+ # Default port for single benchmark (can be overridden)
15
+ DEFAULT_PORT = 8080
@@ -0,0 +1,192 @@
1
+ """Simple Docker manager for benchmarks.
2
+
3
+ Starts/stops benchmark containers with ports exposed to localhost.
4
+
5
+ Async versions (``async_*`` prefix) are the primary implementation.
6
+ The original sync names are thin wrappers around ``asyncio.run()`` so
7
+ existing call-sites remain unchanged. Call the ``async_*`` forms directly
8
+ when already inside an event loop.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import re
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from agent.benchmark.config import DEFAULT_HOST, DEFAULT_PORT
19
+ from agent.tools.executor import AsyncToolExecutor
20
+
21
+ # One shared executor for all Docker operations; Docker I/O is heavy so we
22
+ # cap concurrency at 3 and give long-running builds up to 10 minutes.
23
+ _docker_executor = AsyncToolExecutor(max_workers=3, default_timeout=600.0)
24
+
25
+
26
+ async def async_start_benchmark(benchmark_path: Path, port: int = DEFAULT_PORT) -> dict[str, Any]:
27
+ """Start a benchmark container (async).
28
+
29
+ Args:
30
+ benchmark_path: Path to benchmark directory (contains docker-compose.yml)
31
+ port: Port to expose on host (default: 8080)
32
+
33
+ Returns:
34
+ dict with 'success', 'target_url', and 'message'
35
+ """
36
+ if not benchmark_path.exists():
37
+ return {
38
+ "success": False,
39
+ "target_url": None,
40
+ "message": f"Path not found: {benchmark_path}",
41
+ }
42
+
43
+ compose_file = benchmark_path / "docker-compose.yml"
44
+ if not compose_file.exists():
45
+ return {"success": False, "target_url": None, "message": "No docker-compose.yml found"}
46
+
47
+ print(f"Building benchmark at {benchmark_path.name}...")
48
+ build_result = await _docker_executor.run(
49
+ ["make", "build"],
50
+ cwd=str(benchmark_path),
51
+ timeout=600.0,
52
+ shell=False,
53
+ )
54
+
55
+ if not build_result.success:
56
+ return {
57
+ "success": False,
58
+ "target_url": None,
59
+ "message": f"Build failed: {build_result.stderr or build_result.stdout}",
60
+ }
61
+
62
+ print(f"Starting containers on port {port}...")
63
+ up_result = await _docker_executor.run(
64
+ ["docker", "compose", "up", "-d", "--wait"],
65
+ cwd=str(benchmark_path),
66
+ timeout=120.0,
67
+ shell=False,
68
+ )
69
+
70
+ if not up_result.success:
71
+ return {
72
+ "success": False,
73
+ "target_url": None,
74
+ "message": f"Start failed: {up_result.stderr or up_result.stdout}",
75
+ }
76
+
77
+ actual_port = await async_get_exposed_port(benchmark_path)
78
+ target_url = f"http://{DEFAULT_HOST}:{actual_port or port}"
79
+
80
+ return {
81
+ "success": True,
82
+ "target_url": target_url,
83
+ "port": actual_port or port,
84
+ "message": f"Benchmark started at {target_url}",
85
+ }
86
+
87
+
88
+ async def async_stop_benchmark(benchmark_path: Path) -> dict[str, Any]:
89
+ """Stop a benchmark container (async).
90
+
91
+ Args:
92
+ benchmark_path: Path to benchmark directory
93
+
94
+ Returns:
95
+ dict with 'success' and 'message'
96
+ """
97
+ if not benchmark_path.exists():
98
+ return {"success": False, "message": f"Path not found: {benchmark_path}"}
99
+
100
+ print(f"Stopping benchmark at {benchmark_path.name}...")
101
+ result = await _docker_executor.run(
102
+ ["docker", "compose", "down", "--remove-orphans"],
103
+ cwd=str(benchmark_path),
104
+ timeout=60.0,
105
+ shell=False,
106
+ )
107
+
108
+ if not result.success:
109
+ return {"success": False, "message": f"Stop failed: {result.stderr or result.stdout}"}
110
+
111
+ return {"success": True, "message": "Benchmark stopped"}
112
+
113
+
114
+ async def async_get_exposed_port(benchmark_path: Path) -> int | None:
115
+ """Get the exposed port from running containers (async).
116
+
117
+ Args:
118
+ benchmark_path: Path to benchmark directory
119
+
120
+ Returns:
121
+ Port number or None if not found
122
+ """
123
+ result = await _docker_executor.run(
124
+ ["docker", "compose", "ps", "--format", "{{.Ports}}"],
125
+ cwd=str(benchmark_path),
126
+ timeout=15.0,
127
+ shell=False,
128
+ )
129
+
130
+ if not result.success or not result.stdout.strip():
131
+ return None
132
+
133
+ for line in result.stdout.strip().split("\n"):
134
+ match = re.search(r"0\.0\.0\.0:(\d+)->", line)
135
+ if match:
136
+ return int(match.group(1))
137
+
138
+ return None
139
+
140
+
141
+ async def async_get_running_benchmarks() -> list[dict[str, Any]]:
142
+ """Get list of running benchmark containers (async).
143
+
144
+ Returns:
145
+ List of dicts with container info
146
+ """
147
+ result = await _docker_executor.run(
148
+ ["docker", "ps", "--format", "{{.Names}}\t{{.Ports}}\t{{.Status}}"],
149
+ timeout=15.0,
150
+ shell=False,
151
+ )
152
+
153
+ if not result.success:
154
+ return []
155
+
156
+ running = []
157
+ for line in result.stdout.strip().split("\n"):
158
+ if not line:
159
+ continue
160
+ parts = line.split("\t")
161
+ if len(parts) >= 3:
162
+ name, ports, status = parts[0], parts[1], parts[2]
163
+ if "xben" in name.lower():
164
+ running.append({"name": name, "ports": ports, "status": status})
165
+
166
+ return running
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Synchronous wrappers (backward-compatible call-sites)
171
+ # ---------------------------------------------------------------------------
172
+ # These must NOT be called from within a running event loop.
173
+
174
+
175
+ def start_benchmark(benchmark_path: Path, port: int = DEFAULT_PORT) -> dict[str, Any]:
176
+ """Synchronous wrapper around async_start_benchmark."""
177
+ return asyncio.run(async_start_benchmark(benchmark_path, port))
178
+
179
+
180
+ def stop_benchmark(benchmark_path: Path) -> dict[str, Any]:
181
+ """Synchronous wrapper around async_stop_benchmark."""
182
+ return asyncio.run(async_stop_benchmark(benchmark_path))
183
+
184
+
185
+ def get_exposed_port(benchmark_path: Path) -> int | None:
186
+ """Synchronous wrapper around async_get_exposed_port."""
187
+ return asyncio.run(async_get_exposed_port(benchmark_path))
188
+
189
+
190
+ def get_running_benchmarks() -> list[dict[str, Any]]:
191
+ """Synchronous wrapper around async_get_running_benchmarks."""
192
+ return asyncio.run(async_get_running_benchmarks())
@@ -0,0 +1,99 @@
1
+ """Simple benchmark registry - discovers benchmarks from directory."""
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from agent.benchmark.config import DEFAULT_BENCHMARKS_DIR
8
+
9
+
10
+ @dataclass
11
+ class BenchmarkInfo:
12
+ """Information about a benchmark."""
13
+
14
+ id: str
15
+ name: str
16
+ description: str
17
+ level: int
18
+ tags: list[str]
19
+ path: Path
20
+
21
+
22
+ class BenchmarkRegistry:
23
+ """Discovers benchmarks from a directory."""
24
+
25
+ def __init__(self, benchmarks_dir: Path | None = None):
26
+ self.benchmarks_dir = benchmarks_dir or DEFAULT_BENCHMARKS_DIR
27
+ self._benchmarks: dict[str, BenchmarkInfo] = {}
28
+
29
+ def load(self) -> None:
30
+ """Load all benchmarks from directory."""
31
+ self._benchmarks.clear()
32
+
33
+ if not self.benchmarks_dir.exists():
34
+ raise FileNotFoundError(f"Benchmarks directory not found: {self.benchmarks_dir}")
35
+
36
+ for benchmark_path in sorted(self.benchmarks_dir.iterdir()):
37
+ if not benchmark_path.is_dir():
38
+ continue
39
+
40
+ benchmark_json = benchmark_path / "benchmark.json"
41
+ if not benchmark_json.exists():
42
+ continue
43
+
44
+ try:
45
+ with open(benchmark_json) as f:
46
+ data = json.load(f)
47
+
48
+ info = BenchmarkInfo(
49
+ id=benchmark_path.name,
50
+ name=data.get("name", benchmark_path.name),
51
+ description=data.get("description", ""),
52
+ level=int(data.get("level", 1)),
53
+ tags=data.get("tags", []),
54
+ path=benchmark_path,
55
+ )
56
+ self._benchmarks[info.id] = info
57
+ except (json.JSONDecodeError, KeyError, ValueError) as e:
58
+ print(f"Warning: Failed to parse {benchmark_json}: {e}")
59
+
60
+ def get(self, benchmark_id: str) -> BenchmarkInfo | None:
61
+ """Get benchmark by ID."""
62
+ if not self._benchmarks:
63
+ self.load()
64
+ return self._benchmarks.get(benchmark_id)
65
+
66
+ def list_all(self) -> list[BenchmarkInfo]:
67
+ """List all benchmarks."""
68
+ if not self._benchmarks:
69
+ self.load()
70
+ return sorted(self._benchmarks.values(), key=lambda b: b.id)
71
+
72
+ def filter(
73
+ self,
74
+ tags: list[str] | None = None,
75
+ levels: list[int] | None = None,
76
+ ) -> list[BenchmarkInfo]:
77
+ """Filter benchmarks by tags or levels."""
78
+ if not self._benchmarks:
79
+ self.load()
80
+
81
+ result = list(self._benchmarks.values())
82
+
83
+ if levels:
84
+ result = [b for b in result if b.level in levels]
85
+
86
+ if tags:
87
+ tags_lower = [t.lower() for t in tags]
88
+ result = [b for b in result if any(t.lower() in tags_lower for t in b.tags)]
89
+
90
+ return sorted(result, key=lambda b: b.id)
91
+
92
+ def get_all_tags(self) -> set[str]:
93
+ """Get all unique tags."""
94
+ if not self._benchmarks:
95
+ self.load()
96
+ tags: set[str] = set()
97
+ for b in self._benchmarks.values():
98
+ tags.update(b.tags)
99
+ return tags
agent/core/__init__.py ADDED
File without changes