Skip to content

Commit 10fc0e0

Browse files
Merge pull request #21 from dreadnode/ads/cap-985-build-log_screenshot-tool
[feat] build log screenshot tool
2 parents f33c834 + 57013cc commit 10fc0e0

4 files changed

Lines changed: 321 additions & 5 deletions

File tree

capabilities/web-security/agents/web-security.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ Use tools proactively when they reduce uncertainty or verify a finding. Match th
8686
- Use `get_callback_url` and `check_callbacks` for out-of-band testing (blind SSRF, blind XSS, DNS exfiltration).
8787
- Use `list_free_phone_numbers` and `read_phone_inbox` when signup or MFA flows require SMS verification, unless prompted by the user. Free public numbers first — fall back to `request_private_number`/`poll_private_number` (paid API, needs key via `store_credential`) only when the target blocks public numbers.
8888
- Use `generate_rebinding_hostname` and `list_rebinding_presets` for DNS rebinding SSRF bypass when IP filters validate resolved addresses before fetching.
89+
- Use `log_image_output`, `log_audio_output`, and `log_video_output` when another tool has already written useful PoC media to disk and you need it attached to the current Dreadnode run as typed output. Use `log_file_artifact` when you want the raw file uploaded as an artifact instead of rendered media.
90+
- When a finding is browser-visible or a screenshot materially improves reproducibility, capture the screenshot and attach it to the run. Treat screenshot logging as standard evidence collection, not an optional flourish.
8991
- Use `bbscope_find` at the start of an engagement to check if a target is covered by any bug bounty program and retrieve scope boundaries. Use `bbscope_program` to get full in-scope/out-of-scope details for a specific program. Use `bbscope_targets` to enumerate targets by type (wildcards, domains, URLs, IPs, CIDRs) for reconnaissance. Use `bbscope_updates` to find freshly added targets that may be under-tested.
9092

9193
### MCP tools
@@ -111,6 +113,7 @@ When you find a vulnerability, your report will be reviewed by a senior penteste
111113

112114
- Evidence of full tool invocation and execution output demonstrating impact
113115
- Clear explanation of why this demonstrates a vulnerability and what the security impact is
116+
- When impact is visible in a browser, UI, or rendered document, include a screenshot and log it to the current run when the tooling supports it
114117

115118
**For multi-step exploits:**
116119

capabilities/web-security/tests/conftest.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,25 +34,27 @@ class ToolMessage:
3434
tool_call_id: str | None = None
3535

3636
class _Tool:
37-
def __init__(self, instance: object, method: Any, metadata: dict[str, Any]) -> None:
37+
def __init__(
38+
self, instance: object, method: Any, metadata: dict[str, Any]
39+
) -> None:
3840
self._instance = instance
3941
self._method = method
4042
self.name = metadata["name"]
4143
self.description = metadata["description"]
4244
self.catch = metadata["catch"]
4345
self.parameters_schema = _schema_for(method)
4446

45-
async def handle_tool_call(self, tool_call: ToolCall) -> tuple[ToolMessage, bool]:
47+
async def handle_tool_call(
48+
self, tool_call: ToolCall
49+
) -> tuple[ToolMessage, bool]:
4650
arguments = json.loads(tool_call.function.arguments or "{}")
4751
result = await self._method(**arguments)
4852
return ToolMessage(content=str(result), tool_call_id=tool_call.id), False
4953

5054
def _schema_for(method: Any) -> dict[str, Any]:
5155
signature = inspect.signature(method)
5256
properties = {
53-
name: {"type": "string"}
54-
for name in signature.parameters
55-
if name != "self"
57+
name: {"type": "string"} for name in signature.parameters if name != "self"
5658
}
5759
return {"type": "object", "properties": properties}
5860

@@ -85,6 +87,32 @@ def get_tools(self):
8587
agents.tools = tools
8688
dreadnode.agents = agents
8789

90+
class _Media:
91+
def __init__(self, data: object, caption: str | None = None, **_: Any) -> None:
92+
self.data = data
93+
self.caption = caption
94+
95+
class Image(_Media):
96+
pass
97+
98+
class Audio(_Media):
99+
pass
100+
101+
class Video(_Media):
102+
pass
103+
104+
def log_output(name: str, value: object, **_: Any) -> None:
105+
return None
106+
107+
def log_artifact(local_uri: object, **_: Any) -> None:
108+
return None
109+
110+
dreadnode.Image = Image
111+
dreadnode.Audio = Audio
112+
dreadnode.Video = Video
113+
dreadnode.log_output = log_output
114+
dreadnode.log_artifact = log_artifact
115+
88116
sys.modules["dreadnode"] = dreadnode
89117
sys.modules["dreadnode.agents"] = agents
90118
sys.modules["dreadnode.agents.tools"] = tools
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
"""Tests for multimedia logging tools."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
import importlib.util
7+
8+
import pytest
9+
10+
11+
MODULE_PATH = Path(__file__).resolve().parent.parent / "tools" / "media_logging.py"
12+
SPEC = importlib.util.spec_from_file_location("media_logging", MODULE_PATH)
13+
assert SPEC and SPEC.loader
14+
MODULE = importlib.util.module_from_spec(SPEC)
15+
SPEC.loader.exec_module(MODULE)
16+
17+
MediaLogging = MODULE.MediaLogging
18+
19+
20+
@pytest.fixture
21+
def toolset() -> MediaLogging:
22+
return MediaLogging()
23+
24+
25+
@pytest.fixture
26+
def media_files(tmp_path: Path) -> dict[str, Path]:
27+
image_path = tmp_path / "sample.png"
28+
image_path.write_bytes(b"png-bytes")
29+
30+
audio_path = tmp_path / "sample.wav"
31+
audio_path.write_bytes(b"wav-bytes")
32+
33+
video_path = tmp_path / "sample.mp4"
34+
video_path.write_bytes(b"mp4-bytes")
35+
36+
artifact_path = tmp_path / "notes.txt"
37+
artifact_path.write_text("artifact", encoding="utf-8")
38+
39+
return {
40+
"image": image_path,
41+
"audio": audio_path,
42+
"video": video_path,
43+
"artifact": artifact_path,
44+
}
45+
46+
47+
class TestToolDiscovery:
48+
def test_tools_discovered(self, toolset: MediaLogging) -> None:
49+
names = {tool.name for tool in toolset.get_tools()}
50+
assert names == {
51+
"log_image_output",
52+
"log_audio_output",
53+
"log_video_output",
54+
"log_file_artifact",
55+
}
56+
57+
58+
class TestMediaLogging:
59+
@pytest.mark.asyncio
60+
async def test_log_image_output(
61+
self,
62+
toolset: MediaLogging,
63+
media_files: dict[str, Path],
64+
monkeypatch: pytest.MonkeyPatch,
65+
) -> None:
66+
captured: dict[str, object] = {}
67+
68+
def fake_log_output(name: str, value: object, **_: object) -> None:
69+
captured["name"] = name
70+
captured["value"] = value
71+
72+
monkeypatch.setattr(MODULE.dn, "log_output", fake_log_output)
73+
74+
result = await toolset.log_image_output(
75+
"screenshot/home", str(media_files["image"]), caption="Home"
76+
)
77+
assert result == {
78+
"kind": "image",
79+
"path": str(media_files["image"]),
80+
"name": "screenshot/home",
81+
"caption": "Home",
82+
}
83+
assert captured["name"] == "screenshot/home"
84+
assert isinstance(captured["value"], MODULE.dn.Image)
85+
assert captured["value"].data == media_files["image"]
86+
87+
@pytest.mark.asyncio
88+
async def test_log_audio_output(
89+
self,
90+
toolset: MediaLogging,
91+
media_files: dict[str, Path],
92+
monkeypatch: pytest.MonkeyPatch,
93+
) -> None:
94+
captured: dict[str, object] = {}
95+
96+
def fake_log_output(name: str, value: object, **_: object) -> None:
97+
captured["name"] = name
98+
captured["value"] = value
99+
100+
monkeypatch.setattr(MODULE.dn, "log_output", fake_log_output)
101+
102+
result = await toolset.log_audio_output(
103+
"audio/sample", str(media_files["audio"]), caption="Sample"
104+
)
105+
assert result == {
106+
"kind": "audio",
107+
"path": str(media_files["audio"]),
108+
"name": "audio/sample",
109+
"caption": "Sample",
110+
}
111+
assert captured["name"] == "audio/sample"
112+
assert isinstance(captured["value"], MODULE.dn.Audio)
113+
assert captured["value"].data == media_files["audio"]
114+
115+
@pytest.mark.asyncio
116+
async def test_log_video_output(
117+
self,
118+
toolset: MediaLogging,
119+
media_files: dict[str, Path],
120+
monkeypatch: pytest.MonkeyPatch,
121+
) -> None:
122+
captured: dict[str, object] = {}
123+
124+
def fake_log_output(name: str, value: object, **_: object) -> None:
125+
captured["name"] = name
126+
captured["value"] = value
127+
128+
monkeypatch.setattr(MODULE.dn, "log_output", fake_log_output)
129+
130+
result = await toolset.log_video_output(
131+
"video/demo", str(media_files["video"]), caption="Demo"
132+
)
133+
assert result == {
134+
"kind": "video",
135+
"path": str(media_files["video"]),
136+
"name": "video/demo",
137+
"caption": "Demo",
138+
}
139+
assert captured["name"] == "video/demo"
140+
assert isinstance(captured["value"], MODULE.dn.Video)
141+
assert captured["value"].data == media_files["video"]
142+
143+
@pytest.mark.asyncio
144+
async def test_log_file_artifact(
145+
self,
146+
toolset: MediaLogging,
147+
media_files: dict[str, Path],
148+
monkeypatch: pytest.MonkeyPatch,
149+
) -> None:
150+
captured: dict[str, object] = {}
151+
152+
def fake_log_artifact(local_uri: object, **kwargs: object) -> None:
153+
captured["path"] = local_uri
154+
captured["name"] = kwargs.get("name")
155+
156+
monkeypatch.setattr(MODULE.dn, "log_artifact", fake_log_artifact)
157+
158+
result = await toolset.log_file_artifact(
159+
str(media_files["artifact"]), name="notes.txt"
160+
)
161+
assert result == {
162+
"kind": "artifact",
163+
"path": str(media_files["artifact"]),
164+
"name": "notes.txt",
165+
}
166+
assert captured["path"] == media_files["artifact"]
167+
assert captured["name"] == "notes.txt"
168+
169+
@pytest.mark.asyncio
170+
async def test_missing_file_raises(self, toolset: MediaLogging) -> None:
171+
with pytest.raises(FileNotFoundError):
172+
await toolset.log_image_output("missing", "/tmp/nope.png")
173+
174+
with pytest.raises(FileNotFoundError):
175+
await toolset.log_audio_output("missing", "/tmp/nope.wav")
176+
177+
with pytest.raises(FileNotFoundError):
178+
await toolset.log_video_output("missing", "/tmp/nope.mp4")
179+
180+
with pytest.raises(FileNotFoundError):
181+
await toolset.log_file_artifact("/tmp/nope.bin")
182+
183+
@pytest.mark.asyncio
184+
async def test_directory_path_raises(
185+
self, toolset: MediaLogging, tmp_path: Path
186+
) -> None:
187+
directory = tmp_path / "dir"
188+
directory.mkdir()
189+
190+
with pytest.raises(ValueError):
191+
await toolset.log_file_artifact(str(directory))
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Typed multimedia logging helpers for Dreadnode runs.
2+
3+
These tools mirror the old v1 ``web-agent`` screenshot-ingest idea while using
4+
the current SDK primitives directly. They accept existing local files and log
5+
them either as typed outputs (`Image`, `Audio`, `Video`) or as uploaded
6+
artifacts.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from pathlib import Path
12+
from typing import Annotated, Any
13+
14+
import dreadnode as dn
15+
from dreadnode.agents.tools import Toolset, tool_method
16+
17+
18+
def _existing_file(path: str) -> Path:
19+
file_path = Path(path)
20+
if not file_path.exists():
21+
raise FileNotFoundError(f"File does not exist: {file_path}")
22+
if not file_path.is_file():
23+
raise ValueError(f"Path is not a file: {file_path}")
24+
return file_path
25+
26+
27+
def _result(
28+
kind: str, path: Path, name: str | None = None, caption: str | None = None
29+
) -> dict[str, Any]:
30+
result: dict[str, Any] = {"kind": kind, "path": str(path)}
31+
if name:
32+
result["name"] = name
33+
if caption:
34+
result["caption"] = caption
35+
return result
36+
37+
38+
class MediaLogging(Toolset):
39+
"""Log images, audio, video, and arbitrary files to the current Dreadnode run."""
40+
41+
@tool_method(name="log_image_output", catch=True)
42+
async def log_image_output(
43+
self,
44+
name: Annotated[str, "Output name to log under the current task or run."],
45+
path: Annotated[str, "Path to an existing local image file."],
46+
caption: Annotated[
47+
str | None, "Optional caption shown with the image output."
48+
] = None,
49+
) -> dict[str, Any]:
50+
"""Log an existing image file as a typed Dreadnode output."""
51+
file_path = _existing_file(path)
52+
dn.log_output(name, dn.Image(file_path, caption=caption))
53+
return _result("image", file_path, name=name, caption=caption)
54+
55+
@tool_method(name="log_audio_output", catch=True)
56+
async def log_audio_output(
57+
self,
58+
name: Annotated[str, "Output name to log under the current task or run."],
59+
path: Annotated[str, "Path to an existing local audio file."],
60+
caption: Annotated[
61+
str | None, "Optional caption shown with the audio output."
62+
] = None,
63+
) -> dict[str, Any]:
64+
"""Log an existing audio file as a typed Dreadnode output."""
65+
file_path = _existing_file(path)
66+
dn.log_output(name, dn.Audio(file_path, caption=caption))
67+
return _result("audio", file_path, name=name, caption=caption)
68+
69+
@tool_method(name="log_video_output", catch=True)
70+
async def log_video_output(
71+
self,
72+
name: Annotated[str, "Output name to log under the current task or run."],
73+
path: Annotated[str, "Path to an existing local video file."],
74+
caption: Annotated[
75+
str | None, "Optional caption shown with the video output."
76+
] = None,
77+
) -> dict[str, Any]:
78+
"""Log an existing video file as a typed Dreadnode output."""
79+
file_path = _existing_file(path)
80+
dn.log_output(name, dn.Video(file_path, caption=caption))
81+
return _result("video", file_path, name=name, caption=caption)
82+
83+
@tool_method(name="log_file_artifact", catch=True)
84+
async def log_file_artifact(
85+
self,
86+
path: Annotated[
87+
str, "Path to an existing local file to upload as an artifact."
88+
],
89+
name: Annotated[str | None, "Optional artifact name override."] = None,
90+
) -> dict[str, Any]:
91+
"""Upload an existing local file as a Dreadnode artifact."""
92+
file_path = _existing_file(path)
93+
dn.log_artifact(file_path, name=name)
94+
return _result("artifact", file_path, name=name)

0 commit comments

Comments
 (0)