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.
- agent/__init__.py +17 -0
- agent/benchmark/__init__.py +11 -0
- agent/benchmark/cli.py +179 -0
- agent/benchmark/config.py +15 -0
- agent/benchmark/docker.py +192 -0
- agent/benchmark/registry.py +99 -0
- agent/core/__init__.py +0 -0
- agent/core/agent.py +362 -0
- agent/core/backend.py +1667 -0
- agent/core/config.py +106 -0
- agent/core/controller.py +638 -0
- agent/core/events.py +177 -0
- agent/core/langfuse.py +320 -0
- agent/core/phantom.py +2327 -0
- agent/core/planner.py +493 -0
- agent/core/profiling.py +58 -0
- agent/core/sanitizer.py +104 -0
- agent/core/session.py +228 -0
- agent/core/tracer.py +137 -0
- agent/interface/__init__.py +0 -0
- agent/interface/components/__init__.py +0 -0
- agent/interface/components/activity_feed.py +202 -0
- agent/interface/components/renderers.py +149 -0
- agent/interface/components/splash.py +112 -0
- agent/interface/main.py +1126 -0
- agent/interface/styles.tcss +421 -0
- agent/interface/tui.py +508 -0
- agent/parsing/html_distiller.py +230 -0
- agent/parsing/tool_parser.py +115 -0
- agent/prompts/__init__.py +0 -0
- agent/prompts/pentesting.py +238 -0
- agent/rag_module/knowledge_base.py +116 -0
- agent/tests/benchmark_phantom.py +455 -0
- agent/tools/__init__.py +14 -0
- agent/tools/base.py +99 -0
- agent/tools/executor.py +345 -0
- agent/tools/registry.py +47 -0
- backend/README.md +244 -0
- backend/__init__.py +10 -0
- backend/api/__init__.py +1 -0
- backend/api/routes_scan.py +932 -0
- backend/authz.py +75 -0
- backend/cli.py +261 -0
- backend/compliance/__init__.py +1 -0
- backend/compliance/pdpa_mapping.py +16 -0
- backend/core/__init__.py +1 -0
- backend/core/coverage.py +93 -0
- backend/core/events.py +166 -0
- backend/core/llm_client.py +614 -0
- backend/core/orchestrator.py +402 -0
- backend/core/scan_memory.py +236 -0
- backend/crawler/__init__.py +1 -0
- backend/crawler/csrf.py +75 -0
- backend/crawler/headless.py +232 -0
- backend/crawler/js_analyzer.py +133 -0
- backend/crawler/spider.py +550 -0
- backend/crawler/subdomains.py +279 -0
- backend/daemon.py +252 -0
- backend/db/README.md +86 -0
- backend/db/__init__.py +17 -0
- backend/db/engine.py +87 -0
- backend/db/models.py +206 -0
- backend/db/repository.py +316 -0
- backend/db/schema.sql +247 -0
- backend/modes/__init__.py +5 -0
- backend/modes/base.py +178 -0
- backend/modes/ctf.py +39 -0
- backend/modes/enterprise.py +210 -0
- backend/modes/general.py +226 -0
- backend/modes/registry.py +34 -0
- backend/reporting/__init__.py +0 -0
- backend/reporting/generator.py +285 -0
- backend/schemas/__init__.py +1 -0
- backend/schemas/api.py +423 -0
- backend/tools/__init__.py +62 -0
- backend/tools/dirbrute_tool.py +304 -0
- backend/tools/general_report_tool.py +135 -0
- backend/tools/http_tool.py +351 -0
- backend/tools/nuclei_tool.py +20 -0
- backend/tools/report_tool.py +145 -0
- backend/tools/shell_tools.py +23 -0
- backend/verification/__init__.py +1 -0
- backend/verification/evidence_store.py +125 -0
- backend/verification/general_oracle.py +369 -0
- backend/verification/idor_oracle.py +131 -0
- backend/waf/__init__.py +0 -0
- backend/waf/detector.py +147 -0
- backend/waf/evasion.py +117 -0
- backend/webui/index.html +713 -0
- backend/workspace.py +182 -0
- shadowcat-2.0.0.dist-info/METADATA +360 -0
- shadowcat-2.0.0.dist-info/RECORD +95 -0
- shadowcat-2.0.0.dist-info/WHEEL +4 -0
- shadowcat-2.0.0.dist-info/entry_points.txt +4 -0
- 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
|