1+ import json
12import logging
3+ import subprocess
24from pathlib import Path
35from typing import Annotated
46from 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+
117129class 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+
139355def 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