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/events.py ADDED
@@ -0,0 +1,177 @@
1
+ """Event bus for decoupled communication between TUI and agent."""
2
+
3
+ import contextlib
4
+ import threading
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime
8
+ from enum import Enum, auto
9
+ from typing import Any, Optional
10
+
11
+
12
+ class EventType(Enum):
13
+ """Event types for agent-TUI communication."""
14
+
15
+ # Agent -> UI events (4 essential)
16
+ STATE_CHANGED = auto() # idle, running, paused, completed, error
17
+ MESSAGE = auto() # text output from agent
18
+ TOOL = auto() # tool start/complete
19
+ FLAG_FOUND = auto() # flag detected
20
+
21
+ # UI -> Agent events (2 essential)
22
+ USER_COMMAND = auto() # pause, resume, stop
23
+ USER_INPUT = auto() # instruction text
24
+
25
+
26
+ @dataclass
27
+ class Event:
28
+ """Event container with type and data."""
29
+
30
+ type: EventType
31
+ data: dict[str, Any] = field(default_factory=dict)
32
+ timestamp: datetime = field(default_factory=datetime.now)
33
+
34
+
35
+ class EventBus:
36
+ """Minimal thread-safe event bus for pub/sub communication."""
37
+
38
+ _instance: Optional["EventBus"] = None
39
+ _lock = threading.Lock()
40
+
41
+ def __init__(self) -> None:
42
+ """Initialize event bus."""
43
+ self._handlers: dict[EventType, list[Callable[[Event], None]]] = {}
44
+ self._handler_lock = threading.Lock()
45
+
46
+ @classmethod
47
+ def get(cls) -> "EventBus":
48
+ """Get singleton EventBus instance."""
49
+ with cls._lock:
50
+ if cls._instance is None:
51
+ cls._instance = cls()
52
+ return cls._instance
53
+
54
+ @classmethod
55
+ def reset(cls) -> None:
56
+ """Reset singleton instance (useful for testing)."""
57
+ with cls._lock:
58
+ cls._instance = None
59
+
60
+ def subscribe(self, event_type: EventType, handler: Callable[[Event], None]) -> None:
61
+ """Subscribe a handler to an event type.
62
+
63
+ Args:
64
+ event_type: Type of event to subscribe to
65
+ handler: Callback function to invoke on event
66
+ """
67
+ with self._handler_lock:
68
+ if event_type not in self._handlers:
69
+ self._handlers[event_type] = []
70
+ if handler not in self._handlers[event_type]:
71
+ self._handlers[event_type].append(handler)
72
+
73
+ def unsubscribe(self, event_type: EventType, handler: Callable[[Event], None]) -> None:
74
+ """Unsubscribe a handler from an event type.
75
+
76
+ Args:
77
+ event_type: Type of event to unsubscribe from
78
+ handler: Handler to remove
79
+ """
80
+ with self._handler_lock:
81
+ if event_type in self._handlers:
82
+ with contextlib.suppress(ValueError):
83
+ self._handlers[event_type].remove(handler)
84
+
85
+ def emit(self, event: Event) -> None:
86
+ """Emit an event to all subscribers.
87
+
88
+ Args:
89
+ event: Event to emit
90
+ """
91
+ with self._handler_lock:
92
+ handlers = self._handlers.get(event.type, []).copy()
93
+
94
+ for handler in handlers:
95
+ # Don't let one handler break others
96
+ with contextlib.suppress(Exception):
97
+ handler(event)
98
+
99
+ # Convenience methods for common events
100
+
101
+ def emit_state(
102
+ self,
103
+ state: str,
104
+ details: str = "",
105
+ target: str | None = None,
106
+ task: str | None = None,
107
+ ) -> None:
108
+ """Emit a state change event.
109
+
110
+ Args:
111
+ state: New state (idle, running, paused, completed, error)
112
+ details: Optional details about the state
113
+ target: Optional target IP/URL for session tracking (used by Langfuse)
114
+ task: Optional full task description for session tracking (used by Langfuse)
115
+ """
116
+ data: dict[str, Any] = {"state": state, "details": details}
117
+ if target is not None:
118
+ data["target"] = target
119
+ if task is not None:
120
+ data["task"] = task
121
+ self.emit(Event(EventType.STATE_CHANGED, data))
122
+
123
+ def emit_message(self, text: str, msg_type: str = "info") -> None:
124
+ """Emit a message event.
125
+
126
+ Args:
127
+ text: Message text
128
+ msg_type: Message type (info, success, error, warning)
129
+ """
130
+ self.emit(Event(EventType.MESSAGE, {"text": text, "type": msg_type}))
131
+
132
+ def emit_tool(
133
+ self,
134
+ status: str,
135
+ name: str,
136
+ args: dict[str, Any] | None = None,
137
+ result: Any | None = None,
138
+ ) -> None:
139
+ """Emit a tool event.
140
+
141
+ Args:
142
+ status: Tool status (start, complete, error)
143
+ name: Tool name
144
+ args: Tool arguments
145
+ result: Tool result (for complete status)
146
+ """
147
+ self.emit(
148
+ Event(
149
+ EventType.TOOL,
150
+ {"status": status, "name": name, "args": args or {}, "result": result},
151
+ )
152
+ )
153
+
154
+ def emit_flag(self, flag: str, context: str = "") -> None:
155
+ """Emit a flag found event.
156
+
157
+ Args:
158
+ flag: The flag string
159
+ context: Context where flag was found
160
+ """
161
+ self.emit(Event(EventType.FLAG_FOUND, {"flag": flag, "context": context}))
162
+
163
+ def emit_command(self, command: str) -> None:
164
+ """Emit a user command event.
165
+
166
+ Args:
167
+ command: Command (pause, resume, stop)
168
+ """
169
+ self.emit(Event(EventType.USER_COMMAND, {"command": command}))
170
+
171
+ def emit_input(self, text: str) -> None:
172
+ """Emit a user input event.
173
+
174
+ Args:
175
+ text: User input text
176
+ """
177
+ self.emit(Event(EventType.USER_INPUT, {"text": text}))
agent/core/langfuse.py ADDED
@@ -0,0 +1,320 @@
1
+ """Langfuse observability integration for ShadowCat.
2
+
3
+ Uses Langfuse Python SDK v3 API.
4
+ Docs: https://langfuse.com/docs/sdk/python/low-level-sdk
5
+ """
6
+
7
+ import contextlib
8
+ import logging
9
+ import os
10
+ import uuid
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from agent.core.events import Event, EventBus, EventType
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Langfuse client (lazy-initialized)
19
+ _langfuse_client: Any = None
20
+ _current_span: Any = None # Top-level span (trace equivalent in v3)
21
+ _user_id: str | None = None # Persistent user ID
22
+ _session_target: str | None = None # Current session target
23
+
24
+ # Deferred span creation state (only log if tools are executed)
25
+ _pending_session: dict[str, Any] | None = None # Session data waiting for first tool
26
+ _tool_executed: bool = False # Track if any tool was executed in current session
27
+
28
+
29
+ def _silence_langfuse_loggers() -> None:
30
+ """Silence all Langfuse and OpenTelemetry loggers to prevent output pollution.
31
+
32
+ This prevents network errors, timeouts, and other Langfuse-related issues
33
+ from polluting the main output and breaking the user experience.
34
+ """
35
+ noisy_loggers = [
36
+ "langfuse",
37
+ "opentelemetry",
38
+ "opentelemetry.sdk",
39
+ "opentelemetry.sdk._shared_internal",
40
+ "opentelemetry.exporter",
41
+ "opentelemetry.exporter.otlp",
42
+ "opentelemetry.exporter.otlp.proto.http",
43
+ "urllib3.connectionpool",
44
+ ]
45
+ for logger_name in noisy_loggers:
46
+ noisy_logger = logging.getLogger(logger_name)
47
+ noisy_logger.setLevel(logging.CRITICAL + 1) # Silence completely
48
+ noisy_logger.propagate = False
49
+
50
+
51
+ def _get_or_create_user_id() -> str:
52
+ """Get or create a persistent user ID.
53
+
54
+ The user ID is stored in ~/.shadowcat_agent/user_id and persists across sessions.
55
+ This allows tracking usage patterns per user in Langfuse.
56
+
57
+ Returns:
58
+ A UUID string identifying this user.
59
+ """
60
+ user_id_file = Path.home() / ".shadowcat_agent" / "user_id"
61
+
62
+ try:
63
+ # Try to read existing user ID
64
+ if user_id_file.exists():
65
+ stored_id = user_id_file.read_text().strip()
66
+ if stored_id:
67
+ return stored_id
68
+
69
+ # Generate new user ID
70
+ new_id = str(uuid.uuid4())
71
+
72
+ # Ensure directory exists
73
+ user_id_file.parent.mkdir(parents=True, exist_ok=True)
74
+
75
+ # Save user ID
76
+ user_id_file.write_text(new_id)
77
+ logger.info(f"Generated new user ID: {new_id[:8]}...")
78
+
79
+ return new_id
80
+ except Exception as e:
81
+ # Fallback to a session-only ID if we can't persist
82
+ logger.warning(f"Could not persist user ID: {e}")
83
+ return str(uuid.uuid4())
84
+
85
+
86
+ def init_langfuse(disabled: bool = False) -> bool:
87
+ """Initialize Langfuse client for telemetry.
88
+
89
+ Telemetry is enabled by default to help improve ShadowCat.
90
+ Users can opt out via --no-telemetry flag or LANGFUSE_ENABLED=false.
91
+
92
+ Args:
93
+ disabled: If True, skip initialization (from --no-telemetry flag).
94
+
95
+ Returns:
96
+ True if Langfuse was initialized successfully, False otherwise.
97
+ """
98
+ global _langfuse_client, _user_id
99
+
100
+ # Silence noisy loggers FIRST to prevent any errors from polluting output
101
+ _silence_langfuse_loggers()
102
+
103
+ # Check if disabled via flag
104
+ if disabled:
105
+ return False
106
+
107
+ # Check if disabled via env var (opt-out)
108
+ env_value = os.getenv("LANGFUSE_ENABLED", "true").lower()
109
+ if env_value in ("0", "false", "no", "off"):
110
+ return False
111
+
112
+ # Hardcoded telemetry configuration for ShadowCat project
113
+ # Set environment variables for Langfuse SDK v3
114
+ os.environ.setdefault("LANGFUSE_PUBLIC_KEY", "pk-lf-49d66e88-3a92-478e-92a6-09bae920d69a")
115
+ os.environ.setdefault("LANGFUSE_SECRET_KEY", "sk-lf-ecf59f7b-c031-4745-9250-8a8dc22f1df0")
116
+ os.environ.setdefault("LANGFUSE_HOST", "https://us.cloud.langfuse.com")
117
+
118
+ try:
119
+ from langfuse import get_client
120
+
121
+ _langfuse_client = get_client()
122
+ # Get or create persistent user ID
123
+ _user_id = _get_or_create_user_id()
124
+ # Subscribe to EventBus events
125
+ _subscribe_to_events()
126
+ logger.info(f"Langfuse telemetry initialized (user: {_user_id[:8]}...)")
127
+ return True
128
+ except Exception:
129
+ # Silently fail - don't pollute output with telemetry errors
130
+ _langfuse_client = None
131
+ _user_id = None
132
+ return False
133
+
134
+
135
+ def _subscribe_to_events() -> None:
136
+ """Subscribe handlers to EventBus events."""
137
+ bus = EventBus.get()
138
+ bus.subscribe(EventType.STATE_CHANGED, _handle_state)
139
+ bus.subscribe(EventType.MESSAGE, _handle_message)
140
+ bus.subscribe(EventType.TOOL, _handle_tool)
141
+ bus.subscribe(EventType.FLAG_FOUND, _handle_flag)
142
+
143
+
144
+ def _handle_state(event: Event) -> None:
145
+ """Handle state change events - deferred span creation for successful sessions only.
146
+
147
+ Spans are only created when the first tool is executed, not on session start.
148
+ This ensures we only log sessions where real work was done (tools executed).
149
+ """
150
+ global _current_span, _session_target, _pending_session, _tool_executed
151
+ if not _langfuse_client:
152
+ return
153
+
154
+ state = event.data.get("state")
155
+ details = event.data.get("details", "")
156
+ # Use new target field if available, fallback to details for backward compatibility
157
+ target = event.data.get("target") or details or "unknown"
158
+ task = event.data.get("task", "")
159
+
160
+ try:
161
+ if state == "running":
162
+ # Store session data for deferred span creation
163
+ # Span will be created on first tool execution
164
+ _session_target = target
165
+ _tool_executed = False
166
+
167
+ # Generate a unique session ID for this run
168
+ session_id = str(uuid.uuid4())[:8]
169
+ full_session_id = f"{_user_id[:8]}-{session_id}" if _user_id else session_id
170
+
171
+ _pending_session = {
172
+ "target": _session_target,
173
+ "task": task,
174
+ "session_id": full_session_id,
175
+ }
176
+ logger.debug(f"Langfuse session pending for target: {_session_target}")
177
+
178
+ elif state in ("completed", "error"):
179
+ # Only finalize if span was created (meaning tools were executed)
180
+ if _current_span:
181
+ with contextlib.suppress(Exception):
182
+ _current_span.update(output={"final_state": state, "target": _session_target})
183
+ _current_span.end()
184
+ _langfuse_client.flush()
185
+ logger.debug(f"Langfuse session ended with state: {state}")
186
+ elif _pending_session:
187
+ # Session ended without any tool execution - discard silently
188
+ logger.debug(
189
+ f"Langfuse session discarded (no tools executed) for: {_session_target}"
190
+ )
191
+
192
+ # Reset state
193
+ _current_span = None
194
+ _session_target = None
195
+ _pending_session = None
196
+ _tool_executed = False
197
+ except Exception:
198
+ # Silently fail - don't pollute output with telemetry errors
199
+ pass
200
+
201
+
202
+ def _handle_message(event: Event) -> None:
203
+ """Handle agent messages as nested spans."""
204
+ if not _langfuse_client or not _current_span:
205
+ return
206
+
207
+ try:
208
+ text = event.data.get("text", "")
209
+ msg_type = event.data.get("type", "info")
210
+
211
+ # Create a nested span for the message
212
+ msg_span = _current_span.start_span(
213
+ name="agent-message",
214
+ input={"message_type": msg_type},
215
+ output={"text": text},
216
+ )
217
+ msg_span.end()
218
+ except Exception as e:
219
+ logger.error(f"Langfuse _handle_message error: {e}")
220
+
221
+
222
+ def _handle_tool(event: Event) -> None:
223
+ """Handle tool executions as nested spans.
224
+
225
+ On first tool execution, creates the session span (deferred creation).
226
+ This ensures we only log sessions where real work was done.
227
+ """
228
+ global _current_span, _pending_session, _tool_executed
229
+ if not _langfuse_client:
230
+ return
231
+
232
+ try:
233
+ status = event.data.get("status")
234
+ name = event.data.get("name", "unknown")
235
+ args = event.data.get("args", {})
236
+
237
+ if status == "start":
238
+ # Create the session span on first tool execution (deferred creation)
239
+ if _pending_session and not _current_span:
240
+ with contextlib.suppress(Exception):
241
+ _current_span = _langfuse_client.start_span(
242
+ name=f"shadowcat_agent:{_pending_session['target']}",
243
+ input={
244
+ "target": _pending_session["target"],
245
+ "task": _pending_session.get("task", ""),
246
+ "status": "starting",
247
+ },
248
+ metadata={
249
+ "target": _pending_session["target"],
250
+ "task": _pending_session.get("task", ""),
251
+ "version": "1.0.0",
252
+ "user_id": _user_id,
253
+ "session_id": _pending_session["session_id"],
254
+ },
255
+ )
256
+ if _current_span and hasattr(_current_span, "update_trace"):
257
+ _current_span.update_trace(
258
+ user_id=_user_id,
259
+ session_id=_pending_session["session_id"],
260
+ )
261
+ # Flush so span appears even if agent hangs
262
+ _langfuse_client.flush()
263
+ logger.debug(f"Langfuse session created for: {_pending_session['target']}")
264
+ _pending_session = None # Clear pending state
265
+ _tool_executed = True
266
+
267
+ # Create nested span for tool execution
268
+ if _current_span:
269
+ with contextlib.suppress(Exception):
270
+ tool_span = _current_span.start_span(
271
+ name=f"tool-{name}",
272
+ input=args,
273
+ metadata={"tool_name": name},
274
+ )
275
+ tool_span.end()
276
+ except Exception:
277
+ # Silently fail - don't pollute output with telemetry errors
278
+ pass
279
+
280
+
281
+ def _handle_flag(event: Event) -> None:
282
+ """Handle flag detection as nested spans."""
283
+ if not _langfuse_client or not _current_span:
284
+ return
285
+
286
+ try:
287
+ flag = event.data.get("flag", "")
288
+ context = event.data.get("context", "")
289
+
290
+ # Create a nested span for flag detection
291
+ with contextlib.suppress(Exception):
292
+ flag_span = _current_span.start_span(
293
+ name="flag-found",
294
+ input={"context": context},
295
+ output={"flag": flag},
296
+ metadata={"flag": flag, "context": context},
297
+ )
298
+ flag_span.end()
299
+ except Exception:
300
+ # Silently fail - don't pollute output with telemetry errors
301
+ pass
302
+
303
+
304
+ def shutdown_langfuse() -> None:
305
+ """Flush and shutdown Langfuse client."""
306
+ global _langfuse_client, _current_span, _user_id, _session_target
307
+ global _pending_session, _tool_executed
308
+ if _langfuse_client:
309
+ logger.debug("Langfuse: flushing and shutting down")
310
+ with contextlib.suppress(Exception):
311
+ if _current_span:
312
+ _current_span.end()
313
+ _langfuse_client.flush()
314
+ # If pending session exists without tools executed, discard silently
315
+ _langfuse_client = None
316
+ _current_span = None
317
+ _user_id = None
318
+ _session_target = None
319
+ _pending_session = None
320
+ _tool_executed = False