Skip to content

Commit f720f00

Browse files
authored
feat: add extract_povs command to buttercup-util (#410)
Add new CLI subcommand to extract PoVs, stack traces, and patches from Redis submissions into a structured directory format for easy analysis. Features: - Extracts crash inputs (PoV files) via kubectl cp from cluster pods - Writes fuzzer and tracer stack traces to text files - Exports associated patches with metadata - Organizes output by project/task_id/vulnerability - Supports filtering by task_id and passed_only options - Skips empty patch trackers (placeholders that never received content)
1 parent 9e33ffd commit f720f00

1 file changed

Lines changed: 218 additions & 0 deletions

File tree

common/src/buttercup/common/util_cli.py

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import json
12
import logging
3+
import subprocess
24
from pathlib import Path
35
from typing import Annotated
46
from uuid import uuid4
@@ -114,6 +116,16 @@ class DeleteSettings(BaseModel):
114116
item_id: Annotated[str | None, Field(description="Item ID")] = None
115117

116118

119+
class ExtractPovsSettings(BaseModel):
120+
output_dir: CliPositionalArg[Path] = Field(
121+
description="Output directory for extracted PoVs, stack traces, and patches"
122+
)
123+
task_id: str = Field(default="", description="Filter by task ID (optional)")
124+
passed_only: bool = Field(default=False, description="Only extract vulnerabilities with PASSED PoV result")
125+
namespace: str = Field(default="crs", description="Kubernetes namespace")
126+
pod_label: str = Field(default="app=scheduler", description="Label selector for pod to copy files from")
127+
128+
117129
class Settings(BaseSettings):
118130
redis_url: Annotated[str, Field(default="redis://localhost:6379", description="Redis URL")]
119131
log_level: Annotated[str, Field(default="info", description="Log level")]
@@ -126,6 +138,7 @@ class Settings(BaseSettings):
126138
read_harnesses: CliSubCommand[ReadHarnessWeightSettings]
127139
read_builds: CliSubCommand[ReadBuildsSettings]
128140
read_submissions: CliSubCommand[ReadSubmissionsSettings]
141+
extract_povs: CliSubCommand[ExtractPovsSettings]
129142

130143
class Config:
131144
env_prefix = "BUTTERCUP_MSG_PUBLISHER_"
@@ -136,6 +149,209 @@ class Config:
136149
extra = "allow"
137150

138151

152+
def get_pod_name(namespace: str, label: str) -> str | None:
153+
"""Get the name of a pod matching the label selector."""
154+
try:
155+
result = subprocess.run(
156+
["kubectl", "get", "pods", "-n", namespace, "-l", label, "-o", "jsonpath={.items[0].metadata.name}"],
157+
capture_output=True,
158+
text=True,
159+
check=True,
160+
)
161+
pod_name = result.stdout.strip()
162+
return pod_name if pod_name else None
163+
except subprocess.CalledProcessError as e:
164+
logger.error(f"Failed to get pod name: {e.stderr}")
165+
return None
166+
167+
168+
def kubectl_cp(namespace: str, pod_name: str, remote_path: str, local_path: Path) -> bool:
169+
"""Copy a file from a pod using kubectl cp."""
170+
try:
171+
result = subprocess.run(
172+
["kubectl", "cp", "-n", namespace, f"{pod_name}:{remote_path}", str(local_path)],
173+
capture_output=True,
174+
text=True,
175+
)
176+
if result.returncode != 0:
177+
logger.warning(f"kubectl cp failed for {remote_path}: {result.stderr}")
178+
return False
179+
return True
180+
except Exception as e:
181+
logger.warning(f"kubectl cp exception for {remote_path}: {e}")
182+
return False
183+
184+
185+
def extract_povs(redis: Redis, command: ExtractPovsSettings) -> None:
186+
"""Extract PoVs, stack traces, and patches into a directory structure.
187+
188+
Directory structure:
189+
output_dir/
190+
project_name/
191+
task_id/
192+
vuln_NNN/
193+
crashes/
194+
crash_001/
195+
pov.bin
196+
stacktrace.txt
197+
tracer_stacktrace.txt
198+
metadata.json
199+
patches/
200+
patch_001.patch
201+
patch_002.patch
202+
metadata.json
203+
"""
204+
SUBMISSIONS_KEY = "submissions"
205+
raw_submissions: list = redis.lrange(SUBMISSIONS_KEY, 0, -1)
206+
registry = TaskRegistry(redis)
207+
208+
if not raw_submissions:
209+
logger.info("No submissions found")
210+
return
211+
212+
# Get pod name for kubectl cp
213+
pod_name = get_pod_name(command.namespace, command.pod_label)
214+
if not pod_name:
215+
logger.error(f"No pod found matching label '{command.pod_label}' in namespace '{command.namespace}'")
216+
return
217+
218+
logger.info(f"Using pod '{pod_name}' for file extraction")
219+
220+
output_dir = command.output_dir.resolve()
221+
output_dir.mkdir(parents=True, exist_ok=True)
222+
223+
logger.info(f"Found {len(raw_submissions)} submissions, extracting to {output_dir}")
224+
225+
vuln_counter: dict[str, int] = {} # task_id -> vulnerability counter
226+
stats = {"povs_copied": 0, "povs_failed": 0}
227+
228+
for i, raw in enumerate(raw_submissions):
229+
try:
230+
submission = SubmissionEntry.FromString(raw)
231+
232+
if submission.stop:
233+
logger.debug(f"Skipping stopped submission {i}")
234+
continue
235+
236+
if not submission.crashes:
237+
logger.debug(f"Skipping submission {i} with no crashes")
238+
continue
239+
240+
# Get task info from the first crash
241+
first_crash = submission.crashes[0].crash.crash
242+
task_id = first_crash.target.task_id
243+
244+
if command.task_id and task_id != command.task_id:
245+
logger.debug(f"Skipping submission {i} for task {task_id}")
246+
continue
247+
248+
# Check if any crash passed (if passed_only filter is set)
249+
if command.passed_only:
250+
has_passed = any(c.result == SubmissionResult.PASSED for c in submission.crashes)
251+
if not has_passed:
252+
logger.debug(f"Skipping submission {i} - no PASSED crashes")
253+
continue
254+
255+
# Get task metadata
256+
task = registry.get(task_id)
257+
project_name = task.project_name if task else "unknown"
258+
259+
# Create vulnerability directory
260+
if task_id not in vuln_counter:
261+
vuln_counter[task_id] = 0
262+
vuln_counter[task_id] += 1
263+
vuln_num = vuln_counter[task_id]
264+
265+
vuln_dir = output_dir / project_name / task_id / f"vuln_{vuln_num:03d}"
266+
crashes_dir = vuln_dir / "crashes"
267+
patches_dir = vuln_dir / "patches"
268+
269+
crashes_dir.mkdir(parents=True, exist_ok=True)
270+
patches_dir.mkdir(parents=True, exist_ok=True)
271+
272+
# Extract crashes
273+
for crash_idx, crash_with_id in enumerate(submission.crashes, start=1):
274+
crash = crash_with_id.crash
275+
crash_dir = crashes_dir / f"crash_{crash_idx:03d}"
276+
crash_dir.mkdir(parents=True, exist_ok=True)
277+
278+
# Copy PoV file using kubectl cp
279+
pov_remote_path = crash.crash.crash_input_path
280+
pov_local_path = crash_dir / "pov.bin"
281+
282+
if kubectl_cp(command.namespace, pod_name, pov_remote_path, pov_local_path):
283+
stats["povs_copied"] += 1
284+
else:
285+
stats["povs_failed"] += 1
286+
# Store the path for reference
287+
(crash_dir / "pov_path.txt").write_text(pov_remote_path)
288+
289+
# Write stacktrace
290+
if crash.crash.stacktrace:
291+
(crash_dir / "stacktrace.txt").write_text(crash.crash.stacktrace)
292+
293+
# Write tracer stacktrace
294+
if crash.tracer_stacktrace:
295+
(crash_dir / "tracer_stacktrace.txt").write_text(crash.tracer_stacktrace)
296+
297+
# Write crash metadata
298+
crash_metadata = {
299+
"competition_pov_id": crash_with_id.competition_pov_id,
300+
"result": SubmissionResult.Name(crash_with_id.result) if crash_with_id.result else "NONE",
301+
"harness_name": crash.crash.harness_name,
302+
"crash_token": crash.crash.crash_token,
303+
"crash_input_path": crash.crash.crash_input_path,
304+
"sanitizer": crash.crash.target.sanitizer,
305+
"engine": crash.crash.target.engine,
306+
}
307+
(crash_dir / "metadata.json").write_text(json.dumps(crash_metadata, indent=2))
308+
309+
# Extract patches (skip empty patch trackers - these are placeholders that never received content)
310+
patch_num = 0
311+
for patch_entry in submission.patches:
312+
if not patch_entry.patch:
313+
continue # Skip empty patch trackers
314+
patch_num += 1
315+
patch_file = patches_dir / f"patch_{patch_num:03d}.patch"
316+
patch_file.write_text(patch_entry.patch)
317+
318+
# Write patch metadata
319+
patch_metadata = {
320+
"internal_patch_id": patch_entry.internal_patch_id,
321+
"competition_patch_id": patch_entry.competition_patch_id,
322+
"result": SubmissionResult.Name(patch_entry.result) if patch_entry.result else "NONE",
323+
}
324+
(patches_dir / f"patch_{patch_num:03d}_metadata.json").write_text(json.dumps(patch_metadata, indent=2))
325+
326+
# Write vulnerability metadata
327+
vuln_metadata = {
328+
"task_id": task_id,
329+
"project_name": project_name,
330+
"num_crashes": len(submission.crashes),
331+
"num_patches": patch_num, # Only count non-empty patches
332+
"num_bundles": len(submission.bundles),
333+
"patch_idx": submission.patch_idx,
334+
"stopped": submission.stop,
335+
}
336+
(vuln_dir / "metadata.json").write_text(json.dumps(vuln_metadata, indent=2))
337+
338+
logger.info(
339+
f"Extracted vulnerability {vuln_num} for {project_name}/{task_id}: "
340+
f"{len(submission.crashes)} crashes, {patch_num} patches"
341+
)
342+
343+
except Exception as e:
344+
logger.error(f"Failed to process submission {i}: {e}")
345+
continue
346+
347+
# Print summary
348+
total_vulns = sum(vuln_counter.values())
349+
logger.info(f"Extraction complete: {total_vulns} vulnerabilities across {len(vuln_counter)} tasks")
350+
logger.info(f"PoV files: {stats['povs_copied']} copied, {stats['povs_failed']} failed")
351+
for task_id, count in vuln_counter.items():
352+
logger.info(f" {task_id}: {count} vulnerabilities")
353+
354+
139355
def handle_subcommand(redis: Redis, command: BaseModel | None) -> None:
140356
if command is None:
141357
return
@@ -304,6 +520,8 @@ def handle_subcommand(redis: Redis, command: BaseModel | None) -> None:
304520

305521
print()
306522
logger.info("Done")
523+
elif isinstance(command, ExtractPovsSettings):
524+
extract_povs(redis, command)
307525
elif isinstance(command, ListSettings):
308526
print("Available queues:")
309527
print("\n".join([f"- {name}" for name in get_queue_names()]))

0 commit comments

Comments
 (0)