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/agent.py ADDED
@@ -0,0 +1,362 @@
1
+ """Enhanced Claude Code agent with tracer integration for ShadowCat."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ # Set up debug logging to file
8
+ # Try to write to workspace first, fallback to /tmp if permission denied
9
+ DEBUG_LOG_WORKSPACE = Path("/workspace/shadowcat_agent-debug.log")
10
+ DEBUG_LOG_FALLBACK = Path("/tmp/shadowcat_agent-debug.log")
11
+
12
+ handlers: list[logging.Handler] = [logging.StreamHandler()] # Always log to stderr
13
+ try:
14
+ # Try to create log file in workspace
15
+ handlers.append(logging.FileHandler(DEBUG_LOG_WORKSPACE, mode="w"))
16
+ DEBUG_LOG = DEBUG_LOG_WORKSPACE
17
+ except (PermissionError, OSError):
18
+ # Fallback to /tmp if workspace is not writable
19
+ handlers.append(logging.FileHandler(DEBUG_LOG_FALLBACK, mode="w"))
20
+ DEBUG_LOG = DEBUG_LOG_FALLBACK
21
+ print(
22
+ f"Warning: Could not write to {DEBUG_LOG_WORKSPACE}, using {DEBUG_LOG_FALLBACK} instead",
23
+ flush=True,
24
+ )
25
+
26
+ logging.basicConfig(
27
+ level=logging.DEBUG,
28
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
29
+ handlers=handlers,
30
+ )
31
+
32
+ # Silence chatty third-party libraries that would otherwise flood the rich UI
33
+ # with DEBUG-level internals (every HTTP request, every selector pick, every
34
+ # JSON payload). Their WARNINGs and ERRORs still come through.
35
+ _NOISY_LOGGERS = (
36
+ "httpcore",
37
+ "httpx",
38
+ "openai",
39
+ "urllib3",
40
+ "asyncio",
41
+ "anyio",
42
+ "chromadb",
43
+ )
44
+ for _name in _NOISY_LOGGERS:
45
+ logging.getLogger(_name).setLevel(logging.WARNING)
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+
50
+ # Cap on inlined file content. ~30k chars ≈ ~7500 tokens — large enough for
51
+ # most source/config files but bounded so a stray `--file /var/log/syslog`
52
+ # doesn't blow the context budget on turn 1.
53
+ _MAX_CONTEXT_FILE_CHARS = 30_000
54
+
55
+ # Filenames (or suffixes) that are nearly always autogenerated junk. If --file
56
+ # points at one of these, or a multi-file dump contains a section labelled with
57
+ # one, we strip the section entirely — sending an LLM 500KB of resolved
58
+ # dependency trees is a guaranteed timeout and zero pentest signal.
59
+ _LOCKFILE_NAMES: tuple[str, ...] = (
60
+ "package-lock.json",
61
+ "yarn.lock",
62
+ "pnpm-lock.yaml",
63
+ "npm-shrinkwrap.json",
64
+ "cargo.lock",
65
+ "pipfile.lock",
66
+ "poetry.lock",
67
+ "go.sum",
68
+ "composer.lock",
69
+ "gemfile.lock",
70
+ "mix.lock",
71
+ "podfile.lock",
72
+ )
73
+ _MINIFIED_SUFFIXES: tuple[str, ...] = (".min.js", ".min.css", ".min.mjs", ".map")
74
+
75
+ # Heuristic markers checked against the first N lines of file content for
76
+ # whole-file autogen rejection. Catches the case where the file is a lockfile
77
+ # but the operator passed it under a different name (e.g. `combined.txt`).
78
+ _AUTOGEN_MARKERS: tuple[str, ...] = (
79
+ '"lockfileVersion"', # npm
80
+ '"@generated"',
81
+ "DO NOT EDIT",
82
+ "AUTO-GENERATED",
83
+ "@generated SignedSource", # facebook tooling
84
+ "# This file is automatically @generated by Cargo",
85
+ )
86
+
87
+ # Per-line filter thresholds
88
+ _MAX_LINE_LEN_NO_WS = 500 # any line longer than this with no whitespace = minified
89
+ _LONG_LINE_SAMPLE = 80 # how many leading chars to inspect for whitespace check
90
+
91
+
92
+ def _looks_minified(line: str) -> bool:
93
+ """A line is 'minified' if it's very long and has no whitespace in the prefix."""
94
+ if len(line) < _MAX_LINE_LEN_NO_WS:
95
+ return False
96
+ return " " not in line[:_LONG_LINE_SAMPLE] and "\t" not in line[:_LONG_LINE_SAMPLE]
97
+
98
+
99
+ def _sanitize_context_content(content: str, filename: str = "") -> tuple[str, list[str]]:
100
+ """Strip junk that wastes tokens (lockfiles, minified code, autogen blobs).
101
+
102
+ Two-stage filter:
103
+ 1. Whole-file rejection — if the filename or the file's first few lines
104
+ look like an autogenerated artefact, return empty content with a
105
+ single explanatory note. This catches the package-lock.json case
106
+ that motivated this function.
107
+ 2. Line-level filter — strip individual minified-looking lines (long,
108
+ whitespace-free), which catches inlined bundles in larger dumps.
109
+
110
+ Returns ``(cleaned_content, notes)``. ``notes`` is a list of short
111
+ human-readable strings describing what was removed; both the operator
112
+ (via the CLI banner) and the LLM (via an inline annotation) see them.
113
+ """
114
+ notes: list[str] = []
115
+ lower_name = filename.lower()
116
+
117
+ # Whole-file rejection by filename suffix
118
+ for junk in _LOCKFILE_NAMES:
119
+ if lower_name.endswith(junk):
120
+ return "", [f"file is a lockfile ({junk}) — content omitted"]
121
+ for junk in _MINIFIED_SUFFIXES:
122
+ if lower_name.endswith(junk):
123
+ return "", [f"file is minified/sourcemap ({junk}) — content omitted"]
124
+
125
+ # Whole-file rejection by autogen marker in the first ~4KB
126
+ head = content[:4096]
127
+ for marker in _AUTOGEN_MARKERS:
128
+ if marker in head:
129
+ return "", [f"file appears autogenerated (marker: {marker!r}) — content omitted"]
130
+
131
+ # Line-level filter. keepends=True so we round-trip trailing newlines
132
+ # exactly — important so the clean-file fast path is byte-identical to
133
+ # the input and tests can assert that.
134
+ cleaned_lines: list[str] = []
135
+ stripped_minified = 0
136
+ for line in content.splitlines(keepends=True):
137
+ # Strip trailing newline for the minified check; we want pure
138
+ # content length, not the platform's line terminator.
139
+ bare = line.rstrip("\r\n")
140
+ if _looks_minified(bare):
141
+ stripped_minified += 1
142
+ continue
143
+ cleaned_lines.append(line)
144
+
145
+ if stripped_minified:
146
+ notes.append(f"stripped {stripped_minified} minified/no-whitespace line(s)")
147
+
148
+ return "".join(cleaned_lines), notes
149
+
150
+
151
+ def build_task(
152
+ target: str,
153
+ instruction: str | None = None,
154
+ hint: str | None = None,
155
+ context_file: str | None = None,
156
+ ) -> str:
157
+ """Compose the initial user-message task for the agent.
158
+
159
+ Layered structure — highest-priority signal goes FIRST so the LLM sees it
160
+ before the boilerplate task framing:
161
+
162
+ 1. --hint → HIGH-PRIORITY OPERATOR HINT block (skip generic recon)
163
+ 2. --file → PRIMARY CONTEXT FILE block (contents embedded inline)
164
+ 3. standard task framing (always present)
165
+ 4. --instruction → "Challenge context:" trailer (legacy / lower urgency)
166
+
167
+ Args:
168
+ target: Target URL / IP / host (always present).
169
+ instruction: --instruction value. Background context, lower urgency.
170
+ hint: --hint value. Treated as the agent's starting hypothesis.
171
+ context_file: Path to a file whose contents become primary evidence.
172
+ main.py validates existence before reaching here; this
173
+ function degrades gracefully if the path went missing.
174
+
175
+ Returns:
176
+ The composed task string suitable for backend.query().
177
+ """
178
+ sections: list[str] = []
179
+
180
+ if hint:
181
+ sections.append(
182
+ "═══ HIGH-PRIORITY OPERATOR HINT ═══\n"
183
+ f"{hint}\n"
184
+ "═══════════════════════════════════\n"
185
+ "Treat the above as your starting hypothesis. Skip generic "
186
+ "reconnaissance that does not bear on this hint. Validate the "
187
+ "hint first; only fall back to broad enumeration if it proves wrong."
188
+ )
189
+
190
+ if context_file:
191
+ path = Path(context_file)
192
+ try:
193
+ original_bytes = path.stat().st_size
194
+ raw = path.read_text(encoding="utf-8", errors="replace")
195
+
196
+ # Stage 1: junk filter. Removes lockfiles, minified code, and
197
+ # autogen blobs before we even count characters. The motivating
198
+ # case is `--file combined.txt` where `combined.txt` is a tree
199
+ # dump that accidentally includes package-lock.json; without
200
+ # this step we'd happily ship 500KB of resolved deps at the LLM
201
+ # and trigger the silent OpenRouter timeout this fix exists for.
202
+ content, notes = _sanitize_context_content(raw, filename=path.name)
203
+
204
+ # Stage 2: hard cap as a backstop. If the file is *still* huge
205
+ # after filtering (e.g. a legitimately enormous log), truncate
206
+ # with an explicit marker so the LLM doesn't assume it has the
207
+ # whole picture.
208
+ truncated_chars = 0
209
+ if len(content) > _MAX_CONTEXT_FILE_CHARS:
210
+ truncated_chars = len(content) - _MAX_CONTEXT_FILE_CHARS
211
+ content = (
212
+ content[:_MAX_CONTEXT_FILE_CHARS]
213
+ + f"\n\n... [truncated; {truncated_chars:,} more chars not shown]"
214
+ )
215
+ notes.append(
216
+ f"truncated to first {_MAX_CONTEXT_FILE_CHARS:,} chars "
217
+ f"({truncated_chars:,} chars cut)"
218
+ )
219
+
220
+ # Build the section. If the sanitizer rejected the whole file,
221
+ # tell the LLM clearly rather than embedding an empty code fence.
222
+ header = (
223
+ f"═══ PRIMARY CONTEXT FILE: {path.name} ═══\n"
224
+ f"Operator-provided file. Treat as primary evidence for this challenge.\n"
225
+ f"Path: {path} ({original_bytes:,} bytes on disk)"
226
+ )
227
+ if notes:
228
+ header += "\nSanitizer notes: " + "; ".join(notes)
229
+
230
+ if content.strip():
231
+ sections.append(f"{header}\n```\n{content}\n```")
232
+ else:
233
+ sections.append(
234
+ header + "\n⚠ File was entirely filtered out — ask the operator "
235
+ "to pass a more specific file."
236
+ )
237
+
238
+ # Also log what was stripped so it shows up in the debug log /
239
+ # operator's terminal, not only in the LLM prompt.
240
+ if notes:
241
+ logger.warning("context_file %s sanitized: %s", path, "; ".join(notes))
242
+ except OSError as exc:
243
+ # main.py validates existence before this is called; this branch
244
+ # only catches edge cases (file deleted mid-run, permission flip).
245
+ sections.append(
246
+ f"⚠ Operator specified --file {context_file} but reading "
247
+ f"failed: {exc}. Continuing without it."
248
+ )
249
+
250
+ sections.append(f"Solve this CTF challenge and capture the flag(s): {target}")
251
+
252
+ if instruction:
253
+ sections.append(f"Challenge context: {instruction}")
254
+
255
+ return "\n\n".join(sections)
256
+
257
+
258
+ async def run_pentest(
259
+ target: str,
260
+ custom_instruction: str | None = None,
261
+ model: str | None = None,
262
+ working_dir: str | None = None,
263
+ debug: bool = False,
264
+ resume_session: str | None = None,
265
+ hint: str | None = None,
266
+ context_file: str | None = None,
267
+ ) -> dict[str, Any]:
268
+ """
269
+ Convenience function to run a CTF challenge or penetration test.
270
+
271
+ Uses the new AgentController for lifecycle management and session persistence.
272
+
273
+ Args:
274
+ target: Target challenge/machine to solve
275
+ custom_instruction: Optional custom challenge context or instructions
276
+ model: Optional model override
277
+ working_dir: Optional working directory override
278
+ debug: Enable debug mode with verbose console output
279
+ resume_session: Optional session ID to resume
280
+ hint: Optional high-priority hint injected at top of first prompt
281
+ context_file: Optional path to a file inlined as primary context
282
+
283
+ Returns:
284
+ Dictionary with challenge results including walkthrough and flags found
285
+ """
286
+ from agent.core.config import load_config
287
+ from agent.core.controller import AgentController
288
+
289
+ # Enable verbose console logging in debug mode
290
+ if debug:
291
+ logger.info("=" * 80)
292
+ logger.info("DEBUG MODE ENABLED - CTF CHALLENGE SOLVER")
293
+ logger.info(f"Debug log file: {DEBUG_LOG}")
294
+ logger.info(f"Target: {target}")
295
+ if custom_instruction:
296
+ logger.info(f"Challenge context: {custom_instruction}")
297
+ if hint:
298
+ logger.info(f"Operator hint: {hint}")
299
+ if context_file:
300
+ logger.info(f"Context file: {context_file}")
301
+ if model:
302
+ logger.info(f"Model: {model}")
303
+ if resume_session:
304
+ logger.info(f"Resuming session: {resume_session}")
305
+ logger.info("=" * 80)
306
+
307
+ # Build config
308
+ config_kwargs: dict[str, Any] = {"target": target}
309
+ if custom_instruction:
310
+ config_kwargs["custom_instruction"] = custom_instruction
311
+ if model:
312
+ config_kwargs["llm_model"] = model
313
+ if working_dir:
314
+ config_kwargs["working_directory"] = Path(working_dir)
315
+
316
+ config = load_config(**config_kwargs)
317
+
318
+ # Build task via shared helper so raw/cli/tui modes stay in sync.
319
+ task = build_task(
320
+ target=target,
321
+ instruction=custom_instruction,
322
+ hint=hint,
323
+ context_file=context_file,
324
+ )
325
+
326
+ # Use AgentController for lifecycle management
327
+ controller = AgentController(config)
328
+ result = await controller.run(task, resume_session_id=resume_session)
329
+
330
+ # Map result to legacy format for backward compatibility
331
+ if result.get("success"):
332
+ # Convert flags_found from list of strings to list of dicts if needed
333
+ flags = result.get("flags_found", [])
334
+ if flags and isinstance(flags[0], str):
335
+ flags = [{"flag": f, "context": ""} for f in flags]
336
+
337
+ result = {
338
+ "success": True,
339
+ "output": result.get("output", ""),
340
+ "cost_usd": result.get("cost_usd", 0),
341
+ "flags_found": flags,
342
+ "walkthrough": [], # Walkthrough tracking moved to session
343
+ "session_id": result.get("session_id", ""),
344
+ }
345
+
346
+ if debug:
347
+ logger.info("=" * 80)
348
+ logger.info(f"CHALLENGE COMPLETE - Success: {result.get('success')}")
349
+ if result.get("success"):
350
+ logger.info(f"Flags found: {len(result.get('flags_found', []))}")
351
+ logger.info(f"Cost: ${result.get('cost_usd', 0):.4f}")
352
+ logger.info(f"Session: {result.get('session_id', 'N/A')}")
353
+ for flag_data in result.get("flags_found", []):
354
+ flag = (
355
+ flag_data.get("flag", flag_data) if isinstance(flag_data, dict) else flag_data
356
+ )
357
+ logger.info(f" 🚩 {flag}")
358
+ else:
359
+ logger.error(f"Error: {result.get('error', 'Unknown')}")
360
+ logger.info("=" * 80)
361
+
362
+ return result