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/core/session.py ADDED
@@ -0,0 +1,228 @@
1
+ """Session management for ShadowCat - persistence and state tracking."""
2
+
3
+ import json
4
+ import uuid
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+ from enum import Enum
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ class SessionStatus(Enum):
13
+ """Session lifecycle status."""
14
+
15
+ RUNNING = "running"
16
+ PAUSED = "paused"
17
+ COMPLETED = "completed"
18
+ ERROR = "error"
19
+
20
+
21
+ @dataclass
22
+ class SessionInfo:
23
+ """Session state - framework agnostic."""
24
+
25
+ session_id: str
26
+ target: str
27
+ created_at: datetime
28
+ status: SessionStatus = SessionStatus.RUNNING
29
+ backend_session_id: str | None = None # Backend-specific ID (e.g., Claude session)
30
+ updated_at: datetime | None = None
31
+ task: str = ""
32
+ user_instructions: list[str] = field(default_factory=list)
33
+ flags_found: list[dict[str, str]] = field(default_factory=list)
34
+ total_cost_usd: float = 0.0
35
+ model: str = ""
36
+ last_error: str | None = None
37
+
38
+ def to_dict(self) -> dict[str, Any]:
39
+ """Serialize session to dictionary for JSON storage."""
40
+ return {
41
+ "session_id": self.session_id,
42
+ "target": self.target,
43
+ "created_at": self.created_at.isoformat(),
44
+ "status": self.status.value,
45
+ "backend_session_id": self.backend_session_id,
46
+ "updated_at": self.updated_at.isoformat() if self.updated_at else None,
47
+ "task": self.task,
48
+ "user_instructions": self.user_instructions,
49
+ "flags_found": self.flags_found,
50
+ "total_cost_usd": self.total_cost_usd,
51
+ "model": self.model,
52
+ "last_error": self.last_error,
53
+ }
54
+
55
+ @classmethod
56
+ def from_dict(cls, data: dict[str, Any]) -> "SessionInfo":
57
+ """Deserialize session from dictionary."""
58
+ return cls(
59
+ session_id=data["session_id"],
60
+ target=data["target"],
61
+ created_at=datetime.fromisoformat(data["created_at"]),
62
+ status=SessionStatus(data["status"]),
63
+ backend_session_id=data.get("backend_session_id"),
64
+ updated_at=(
65
+ datetime.fromisoformat(data["updated_at"]) if data.get("updated_at") else None
66
+ ),
67
+ task=data.get("task", ""),
68
+ user_instructions=data.get("user_instructions", []),
69
+ flags_found=data.get("flags_found", []),
70
+ total_cost_usd=data.get("total_cost_usd", 0.0),
71
+ model=data.get("model", ""),
72
+ last_error=data.get("last_error"),
73
+ )
74
+
75
+
76
+ class SessionStore:
77
+ """Simple file-based session persistence."""
78
+
79
+ SESSIONS_DIR = Path.home() / ".shadowcat_agent" / "sessions"
80
+
81
+ def __init__(self, sessions_dir: Path | None = None):
82
+ """Initialize session store.
83
+
84
+ Args:
85
+ sessions_dir: Optional custom sessions directory
86
+ """
87
+ self._sessions_dir = sessions_dir or self.SESSIONS_DIR
88
+ self._sessions_dir.mkdir(parents=True, exist_ok=True)
89
+ self._current: SessionInfo | None = None
90
+
91
+ def create(self, target: str, task: str, model: str) -> SessionInfo:
92
+ """Create a new session.
93
+
94
+ Args:
95
+ target: Target URL/IP/domain
96
+ task: Task description
97
+ model: Model name
98
+
99
+ Returns:
100
+ New SessionInfo instance
101
+ """
102
+ session = SessionInfo(
103
+ session_id=str(uuid.uuid4())[:8],
104
+ target=target,
105
+ created_at=datetime.now(),
106
+ task=task,
107
+ model=model,
108
+ )
109
+ self._current = session
110
+ self.save()
111
+ return session
112
+
113
+ @property
114
+ def current(self) -> SessionInfo | None:
115
+ """Get current active session."""
116
+ return self._current
117
+
118
+ def save(self) -> None:
119
+ """Save current session to disk."""
120
+ if not self._current:
121
+ return
122
+ self._current.updated_at = datetime.now()
123
+ path = self._sessions_dir / f"{self._current.session_id}.json"
124
+ path.write_text(json.dumps(self._current.to_dict(), indent=2))
125
+
126
+ def load(self, session_id: str) -> SessionInfo | None:
127
+ """Load a session by ID.
128
+
129
+ Args:
130
+ session_id: Session ID to load
131
+
132
+ Returns:
133
+ SessionInfo if found, None otherwise
134
+ """
135
+ path = self._sessions_dir / f"{session_id}.json"
136
+ if not path.exists():
137
+ return None
138
+ try:
139
+ self._current = SessionInfo.from_dict(json.loads(path.read_text()))
140
+ return self._current
141
+ except (json.JSONDecodeError, KeyError, ValueError):
142
+ return None
143
+
144
+ def list_sessions(self, target: str | None = None) -> list[SessionInfo]:
145
+ """List all sessions, optionally filtered by target.
146
+
147
+ Args:
148
+ target: Optional target filter
149
+
150
+ Returns:
151
+ List of SessionInfo, sorted by creation date (newest first)
152
+ """
153
+ sessions = []
154
+ for path in self._sessions_dir.glob("*.json"):
155
+ try:
156
+ session = SessionInfo.from_dict(json.loads(path.read_text()))
157
+ if target is None or session.target == target:
158
+ sessions.append(session)
159
+ except (json.JSONDecodeError, KeyError, ValueError):
160
+ continue
161
+ return sorted(sessions, key=lambda s: s.created_at, reverse=True)
162
+
163
+ def get_latest(self, target: str | None = None) -> SessionInfo | None:
164
+ """Get the most recent session.
165
+
166
+ Args:
167
+ target: Optional target filter
168
+
169
+ Returns:
170
+ Most recent SessionInfo if any exist
171
+ """
172
+ sessions = self.list_sessions(target)
173
+ return sessions[0] if sessions else None
174
+
175
+ def delete(self, session_id: str) -> bool:
176
+ """Delete a session by ID.
177
+
178
+ Args:
179
+ session_id: Session ID to delete
180
+
181
+ Returns:
182
+ True if deleted, False if not found
183
+ """
184
+ path = self._sessions_dir / f"{session_id}.json"
185
+ if path.exists():
186
+ path.unlink()
187
+ if self._current and self._current.session_id == session_id:
188
+ self._current = None
189
+ return True
190
+ return False
191
+
192
+ # Convenience methods for updating current session
193
+
194
+ def update_status(self, status: SessionStatus) -> None:
195
+ """Update current session status."""
196
+ if self._current:
197
+ self._current.status = status
198
+ self.save()
199
+
200
+ def add_instruction(self, instruction: str) -> None:
201
+ """Add a user instruction to current session."""
202
+ if self._current:
203
+ self._current.user_instructions.append(instruction)
204
+ self.save()
205
+
206
+ def add_flag(self, flag: str, context: str) -> None:
207
+ """Add a found flag to current session."""
208
+ if self._current:
209
+ self._current.flags_found.append({"flag": flag, "context": context})
210
+ self.save()
211
+
212
+ def set_backend_session_id(self, backend_id: str) -> None:
213
+ """Set the backend-specific session ID."""
214
+ if self._current:
215
+ self._current.backend_session_id = backend_id
216
+ self.save()
217
+
218
+ def add_cost(self, cost: float) -> None:
219
+ """Add to total cost."""
220
+ if self._current:
221
+ self._current.total_cost_usd += cost
222
+ self.save()
223
+
224
+ def set_error(self, error: str) -> None:
225
+ """Set last error."""
226
+ if self._current:
227
+ self._current.last_error = error
228
+ self.save()
agent/core/tracer.py ADDED
@@ -0,0 +1,137 @@
1
+ """Activity tracer for tracking agent actions and tool executions."""
2
+
3
+ import threading
4
+ from collections.abc import Callable
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+
9
+ class Tracer:
10
+ """Lightweight tracer for tracking agent activity."""
11
+
12
+ def __init__(self) -> None:
13
+ """Initialize the tracer."""
14
+ self._lock = threading.Lock()
15
+ self._activities: list[dict[str, Any]] = []
16
+ self._on_activity_callback: Callable[[dict[str, Any]], None] | None = None
17
+
18
+ def set_activity_callback(self, callback: Callable[[dict[str, Any]], None]) -> None:
19
+ """Set callback function to be called when new activity is tracked."""
20
+ self._on_activity_callback = callback
21
+
22
+ def track_message(
23
+ self,
24
+ message: str,
25
+ message_type: str = "info",
26
+ timestamp: datetime | None = None,
27
+ ) -> None:
28
+ """Track a simple message."""
29
+ if timestamp is None:
30
+ timestamp = datetime.now()
31
+
32
+ activity = {
33
+ "type": "message",
34
+ "message": message,
35
+ "message_type": message_type,
36
+ "timestamp": timestamp,
37
+ }
38
+
39
+ with self._lock:
40
+ self._activities.append(activity)
41
+
42
+ if self._on_activity_callback:
43
+ self._on_activity_callback(activity)
44
+
45
+ def track_tool_start(
46
+ self,
47
+ tool_name: str,
48
+ args: dict[str, Any],
49
+ timestamp: datetime | None = None,
50
+ ) -> int:
51
+ """Track the start of a tool execution. Returns activity ID."""
52
+ if timestamp is None:
53
+ timestamp = datetime.now()
54
+
55
+ activity = {
56
+ "type": "tool",
57
+ "tool_name": tool_name,
58
+ "args": args,
59
+ "status": "running",
60
+ "result": None,
61
+ "timestamp": timestamp,
62
+ }
63
+
64
+ with self._lock:
65
+ activity_id = len(self._activities)
66
+ self._activities.append(activity)
67
+
68
+ if self._on_activity_callback:
69
+ self._on_activity_callback(activity)
70
+
71
+ return activity_id
72
+
73
+ def track_tool_complete(
74
+ self,
75
+ activity_id: int,
76
+ result: Any = None,
77
+ status: str = "completed",
78
+ ) -> None:
79
+ """Mark a tool execution as complete."""
80
+ with self._lock:
81
+ if 0 <= activity_id < len(self._activities):
82
+ activity = self._activities[activity_id]
83
+ activity["status"] = status
84
+ activity["result"] = result
85
+
86
+ if self._on_activity_callback:
87
+ self._on_activity_callback(activity)
88
+
89
+ def track_agent_status(
90
+ self,
91
+ status: str,
92
+ details: str | None = None,
93
+ ) -> None:
94
+ """Track agent status change."""
95
+ message = f"Agent status: {status}"
96
+ if details:
97
+ message = f"{message} - {details}"
98
+
99
+ self.track_message(message, message_type="info")
100
+
101
+ def get_recent_activities(self, count: int = 50) -> list[dict[str, Any]]:
102
+ """Get the most recent activities."""
103
+ with self._lock:
104
+ return self._activities[-count:] if self._activities else []
105
+
106
+ def get_all_activities(self) -> list[dict[str, Any]]:
107
+ """Get all tracked activities."""
108
+ with self._lock:
109
+ return self._activities.copy()
110
+
111
+ def clear(self) -> None:
112
+ """Clear all tracked activities."""
113
+ with self._lock:
114
+ self._activities.clear()
115
+
116
+
117
+ # Global tracer instance
118
+ _global_tracer: Tracer | None = None
119
+ _tracer_lock = threading.Lock()
120
+
121
+
122
+ def get_global_tracer() -> Tracer:
123
+ """Get or create the global tracer instance."""
124
+ global _global_tracer
125
+
126
+ if _global_tracer is None:
127
+ with _tracer_lock:
128
+ if _global_tracer is None:
129
+ _global_tracer = Tracer()
130
+
131
+ return _global_tracer
132
+
133
+
134
+ def set_global_tracer(tracer: Tracer) -> None:
135
+ """Set a custom global tracer."""
136
+ global _global_tracer
137
+ _global_tracer = tracer
File without changes
File without changes
@@ -0,0 +1,202 @@
1
+ """Activity feed component for displaying real-time agent updates."""
2
+
3
+ from collections.abc import Iterator
4
+ from datetime import datetime
5
+ from typing import Any
6
+
7
+ from rich.markup import escape as rich_escape
8
+ from textual.containers import VerticalScroll
9
+ from textual.widgets import Static
10
+
11
+
12
+ def escape_markup(text: str) -> str:
13
+ """Escape Rich markup characters."""
14
+ return str(rich_escape(text))
15
+
16
+
17
+ class ActivityFeed(VerticalScroll):
18
+ """Scrollable activity feed showing agent actions in real-time."""
19
+
20
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
21
+ """Initialize activity feed."""
22
+ super().__init__(*args, **kwargs)
23
+ self._content_widget: Static | None = None
24
+ self._activities: list[dict[str, Any]] = []
25
+
26
+ def compose(self) -> Iterator[Static]:
27
+ """Create the feed content area."""
28
+ content = Static("", id="activity_content")
29
+ self._content_widget = content
30
+ yield content
31
+
32
+ def add_message(
33
+ self, message: str, message_type: str = "info", timestamp: datetime | None = None
34
+ ) -> None:
35
+ """Add a simple text message to the feed."""
36
+ if timestamp is None:
37
+ timestamp = datetime.now()
38
+
39
+ activity = {
40
+ "type": "message",
41
+ "message": message,
42
+ "message_type": message_type,
43
+ "timestamp": timestamp,
44
+ }
45
+ self._activities.append(activity)
46
+ self._render_activities()
47
+
48
+ def add_tool_execution(
49
+ self,
50
+ tool_name: str,
51
+ args: dict[str, Any],
52
+ status: str = "running",
53
+ result: Any = None,
54
+ timestamp: datetime | None = None,
55
+ ) -> None:
56
+ """Add a tool execution block to the feed."""
57
+ if timestamp is None:
58
+ timestamp = datetime.now()
59
+
60
+ activity = {
61
+ "type": "tool",
62
+ "tool_name": tool_name,
63
+ "args": args,
64
+ "status": status,
65
+ "result": result,
66
+ "timestamp": timestamp,
67
+ }
68
+ self._activities.append(activity)
69
+ self._render_activities()
70
+
71
+ def update_last_tool_status(self, status: str, result: Any = None) -> None:
72
+ """Update the status of the most recent tool execution."""
73
+ # Find the last tool activity
74
+ for activity in reversed(self._activities):
75
+ if activity["type"] == "tool":
76
+ activity["status"] = status
77
+ if result is not None:
78
+ activity["result"] = result
79
+ break
80
+
81
+ self._render_activities()
82
+
83
+ def clear(self) -> None:
84
+ """Clear all activities from the feed."""
85
+ self._activities.clear()
86
+ if self._content_widget:
87
+ self._content_widget.update("")
88
+
89
+ def _render_activities(self) -> None:
90
+ """Render all activities to the content widget."""
91
+ if not self._content_widget:
92
+ return
93
+
94
+ if not self._activities:
95
+ placeholder = (
96
+ "\n\n[dim italic]Waiting for agent to start...[/]\n\n"
97
+ "[dim]The activity feed will show real-time updates here.[/]"
98
+ )
99
+ self._content_widget.update(placeholder)
100
+ self._content_widget.set_classes("placeholder")
101
+ return
102
+
103
+ lines = []
104
+ for activity in self._activities:
105
+ if activity["type"] == "message":
106
+ lines.append(self._render_message(activity))
107
+ elif activity["type"] == "tool":
108
+ lines.append(self._render_tool(activity))
109
+
110
+ content = "\n\n".join(lines)
111
+ self._content_widget.update(content)
112
+ self._content_widget.remove_class("placeholder")
113
+
114
+ # Auto-scroll to bottom
115
+ self.call_later(self.scroll_end, animate=False)
116
+
117
+ def _render_message(self, activity: dict[str, Any]) -> str:
118
+ """Render a simple message activity."""
119
+ timestamp = activity["timestamp"].strftime("%H:%M:%S")
120
+ message = escape_markup(activity["message"])
121
+ message_type = activity["message_type"]
122
+
123
+ # Type-specific styling
124
+ if message_type == "success":
125
+ icon = "[#10b981]✓[/]"
126
+ style = "activity-status-success"
127
+ elif message_type == "error":
128
+ icon = "[#ef4444]✗[/]"
129
+ style = "activity-status-error"
130
+ elif message_type == "warning":
131
+ icon = "[#f59e0b]⚠[/]"
132
+ style = ""
133
+ else:
134
+ icon = "[#6366f1]●[/]"
135
+ style = "activity-status-active"
136
+
137
+ return f"[dim]{timestamp}[/] {icon} [{style}]{message}[/]" if style else f"{message}"
138
+
139
+ def _render_tool(self, activity: dict[str, Any]) -> str:
140
+ """Render a tool execution block."""
141
+ timestamp = activity["timestamp"].strftime("%H:%M:%S")
142
+ tool_name = activity["tool_name"]
143
+ args = activity["args"]
144
+ status = activity["status"]
145
+ result = activity["result"]
146
+
147
+ # Determine tool type for styling
148
+ tool_class = self._get_tool_class(tool_name)
149
+
150
+ # Status indicator
151
+ if status == "running":
152
+ status_icon = "[#f59e0b]●[/] In progress..."
153
+ elif status == "completed":
154
+ status_icon = "[#10b981]✓[/] Done"
155
+ elif status == "failed":
156
+ status_icon = "[#ef4444]✗[/] Failed"
157
+ else:
158
+ status_icon = "[dim]○[/] Unknown"
159
+
160
+ # Build the tool block
161
+ lines = []
162
+ lines.append(f"[dim]{timestamp}[/]")
163
+
164
+ # Tool header
165
+ header_class = "tool-header" if tool_class else ""
166
+ lines.append(f"[{header_class}]▍ {escape_markup(tool_name)}[/] {status_icon}")
167
+
168
+ # Arguments
169
+ if args:
170
+ for key, value in list(args.items())[:3]: # Show first 3 args
171
+ value_str = str(value)
172
+ if len(value_str) > 100:
173
+ value_str = value_str[:97] + "..."
174
+
175
+ # Special formatting for command
176
+ if key == "command" and tool_name == "terminal_execute":
177
+ lines.append(f" [#10b981]$ {escape_markup(value_str)}[/]")
178
+ else:
179
+ lines.append(f" [dim]{key}:[/] {escape_markup(value_str)}")
180
+
181
+ # Result (if completed)
182
+ if status in ("completed", "failed") and result:
183
+ result_str = str(result)
184
+ if len(result_str) > 200:
185
+ result_str = result_str[:197] + "..."
186
+
187
+ lines.append(f" [dim]→[/] {escape_markup(result_str)}")
188
+
189
+ content = "\n".join(lines)
190
+
191
+ # Wrap in styled container
192
+ return f"[{tool_class}]{content}[/]"
193
+
194
+ def _get_tool_class(self, tool_name: str) -> str:
195
+ """Get the CSS class for a tool type."""
196
+ tool_classes = {
197
+ "terminal_execute": "tool-terminal",
198
+ "thinking": "tool-thinking",
199
+ "result": "tool-result",
200
+ "error": "tool-error",
201
+ }
202
+ return tool_classes.get(tool_name, "tool-block")