mirror of
https://github.com/FuzzingLabs/fuzzforge_ai.git
synced 2026-08-18 06:17:15 +02:00
feat: Add Python fuzzing vertical with Atheris integration
This commit implements a complete Python fuzzing workflow using Atheris: ## Python Worker (workers/python/) - Dockerfile with Python 3.11, Atheris, and build tools - Generic worker.py for dynamic workflow discovery - requirements.txt with temporalio, boto3, atheris dependencies - Added to docker-compose.temporal.yaml with dedicated cache volume ## AtherisFuzzer Module (backend/toolbox/modules/fuzzer/) - Reusable module extending BaseModule - Auto-discovers fuzz targets (fuzz_*.py, *_fuzz.py, fuzz_target.py) - Recursive search to find targets in nested directories - Dynamically loads TestOneInput() function - Configurable max_iterations and timeout - Real-time stats callback support for live monitoring - Returns findings as ModuleFinding objects ## Atheris Fuzzing Workflow (backend/toolbox/workflows/atheris_fuzzing/) - Temporal workflow for orchestrating fuzzing - Downloads user code from MinIO - Executes AtherisFuzzer module - Uploads results to MinIO - Cleans up cache after execution - metadata.yaml with vertical: python for routing ## Test Project (test_projects/python_fuzz_waterfall/) - Demonstrates stateful waterfall vulnerability - main.py with check_secret() that leaks progress - fuzz_target.py with Atheris TestOneInput() harness - Complete README with usage instructions ## Backend Fixes - Fixed parameter merging in REST API endpoints (workflows.py) - Changed workflow parameter passing from positional args to kwargs (manager.py) - Default parameters now properly merged with user parameters ## Testing ✅ Worker discovered AtherisFuzzingWorkflow ✅ Workflow executed end-to-end successfully ✅ Fuzz target auto-discovered in nested directories ✅ Atheris ran 100,000 iterations ✅ Results uploaded and cache cleaned
This commit is contained in:
@@ -15,8 +15,10 @@ API endpoints for workflow management with enhanced error handling
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
import tempfile
|
||||
import shutil
|
||||
from typing import List, Dict, Any, Optional
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, Form
|
||||
from pathlib import Path
|
||||
|
||||
from src.models.findings import (
|
||||
@@ -29,6 +31,16 @@ from src.temporal.discovery import WorkflowDiscovery
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configuration for file uploads
|
||||
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 * 1024 # 10 GB
|
||||
ALLOWED_CONTENT_TYPES = [
|
||||
"application/gzip",
|
||||
"application/x-gzip",
|
||||
"application/x-tar",
|
||||
"application/x-compressed-tar",
|
||||
"application/octet-stream", # Generic binary
|
||||
]
|
||||
|
||||
router = APIRouter(prefix="/workflows", tags=["workflows"])
|
||||
|
||||
|
||||
@@ -209,8 +221,11 @@ async def submit_workflow(
|
||||
metadata={"workflow": workflow_name}
|
||||
)
|
||||
|
||||
# Prepare workflow parameters
|
||||
workflow_params = submission.parameters or {}
|
||||
# Merge default parameters with user parameters
|
||||
metadata = workflow_info.metadata or {}
|
||||
defaults = metadata.get("default_parameters", {})
|
||||
user_params = submission.parameters or {}
|
||||
workflow_params = {**defaults, **user_params}
|
||||
|
||||
# Start workflow execution
|
||||
handle = await temporal_mgr.run_workflow(
|
||||
@@ -321,6 +336,180 @@ async def submit_workflow(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{workflow_name}/upload-and-submit", response_model=RunSubmissionResponse)
|
||||
async def upload_and_submit_workflow(
|
||||
workflow_name: str,
|
||||
file: UploadFile = File(..., description="Target file or tarball to analyze"),
|
||||
parameters: Optional[str] = Form(None, description="JSON-encoded workflow parameters"),
|
||||
volume_mode: str = Form("ro", description="Volume mount mode (ro/rw)"),
|
||||
timeout: Optional[int] = Form(None, description="Timeout in seconds"),
|
||||
temporal_mgr=Depends(get_temporal_manager)
|
||||
) -> RunSubmissionResponse:
|
||||
"""
|
||||
Upload a target file/tarball and submit workflow for execution.
|
||||
|
||||
This endpoint accepts multipart/form-data uploads and is the recommended
|
||||
way to submit workflows from remote CLI clients.
|
||||
|
||||
Args:
|
||||
workflow_name: Name of the workflow to execute
|
||||
file: Target file or tarball (compressed directory)
|
||||
parameters: JSON string of workflow parameters (optional)
|
||||
volume_mode: Volume mount mode - "ro" (read-only) or "rw" (read-write)
|
||||
timeout: Execution timeout in seconds (optional)
|
||||
|
||||
Returns:
|
||||
Run submission response with run_id and initial status
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if workflow not found, 400 for invalid parameters,
|
||||
413 if file too large
|
||||
"""
|
||||
if workflow_name not in temporal_mgr.workflows:
|
||||
available_workflows = list(temporal_mgr.workflows.keys())
|
||||
error_response = create_structured_error_response(
|
||||
error_type="WorkflowNotFound",
|
||||
message=f"Workflow '{workflow_name}' not found",
|
||||
workflow_name=workflow_name,
|
||||
suggestions=[
|
||||
f"Available workflows: {', '.join(available_workflows)}",
|
||||
"Use GET /workflows/ to see all available workflows"
|
||||
]
|
||||
)
|
||||
raise HTTPException(status_code=404, detail=error_response)
|
||||
|
||||
temp_file_path = None
|
||||
|
||||
try:
|
||||
# Validate file size
|
||||
file_size = 0
|
||||
chunk_size = 1024 * 1024 # 1MB chunks
|
||||
|
||||
# Create temporary file
|
||||
temp_fd, temp_file_path = tempfile.mkstemp(suffix=".tar.gz")
|
||||
|
||||
logger.info(f"Receiving file upload for workflow '{workflow_name}': {file.filename}")
|
||||
|
||||
# Stream file to disk
|
||||
with open(temp_fd, 'wb') as temp_file:
|
||||
while True:
|
||||
chunk = await file.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
file_size += len(chunk)
|
||||
|
||||
# Check size limit
|
||||
if file_size > MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=create_structured_error_response(
|
||||
error_type="FileTooLarge",
|
||||
message=f"File size exceeds maximum allowed size of {MAX_UPLOAD_SIZE / (1024**3):.1f} GB",
|
||||
workflow_name=workflow_name,
|
||||
suggestions=[
|
||||
"Reduce the size of your target directory",
|
||||
"Exclude unnecessary files (build artifacts, dependencies, etc.)",
|
||||
"Consider splitting into smaller analysis targets"
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
temp_file.write(chunk)
|
||||
|
||||
logger.info(f"Received file: {file_size / (1024**2):.2f} MB")
|
||||
|
||||
# Parse parameters
|
||||
workflow_params = {}
|
||||
if parameters:
|
||||
try:
|
||||
import json
|
||||
workflow_params = json.loads(parameters)
|
||||
if not isinstance(workflow_params, dict):
|
||||
raise ValueError("Parameters must be a JSON object")
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=create_structured_error_response(
|
||||
error_type="InvalidParameters",
|
||||
message=f"Invalid parameters JSON: {e}",
|
||||
workflow_name=workflow_name,
|
||||
suggestions=["Ensure parameters is valid JSON object"]
|
||||
)
|
||||
)
|
||||
|
||||
# Upload to MinIO
|
||||
target_id = await temporal_mgr.upload_target(
|
||||
file_path=Path(temp_file_path),
|
||||
user_id="api-user",
|
||||
metadata={
|
||||
"workflow": workflow_name,
|
||||
"original_filename": file.filename,
|
||||
"upload_method": "multipart"
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"Uploaded to MinIO with target_id: {target_id}")
|
||||
|
||||
# Merge default parameters with user parameters
|
||||
workflow_info = temporal_mgr.workflows.get(workflow_name)
|
||||
metadata = workflow_info.metadata or {}
|
||||
defaults = metadata.get("default_parameters", {})
|
||||
workflow_params = {**defaults, **workflow_params}
|
||||
|
||||
# Start workflow execution
|
||||
handle = await temporal_mgr.run_workflow(
|
||||
workflow_name=workflow_name,
|
||||
target_id=target_id,
|
||||
workflow_params=workflow_params
|
||||
)
|
||||
|
||||
run_id = handle.id
|
||||
|
||||
# Initialize fuzzing tracking if needed
|
||||
workflow_info = temporal_mgr.workflows.get(workflow_name, {})
|
||||
workflow_tags = workflow_info.metadata.get("tags", []) if hasattr(workflow_info, 'metadata') else []
|
||||
if "fuzzing" in workflow_tags or "fuzz" in workflow_name.lower():
|
||||
from src.api.fuzzing import initialize_fuzzing_tracking
|
||||
initialize_fuzzing_tracking(run_id, workflow_name)
|
||||
|
||||
return RunSubmissionResponse(
|
||||
run_id=run_id,
|
||||
status="RUNNING",
|
||||
workflow=workflow_name,
|
||||
message=f"Workflow '{workflow_name}' submitted successfully with uploaded target"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to upload and submit workflow '{workflow_name}': {e}")
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
|
||||
error_response = create_structured_error_response(
|
||||
error_type="WorkflowSubmissionError",
|
||||
message=f"Failed to process upload and submit workflow: {str(e)}",
|
||||
workflow_name=workflow_name,
|
||||
suggestions=[
|
||||
"Check if the uploaded file is a valid tarball",
|
||||
"Verify MinIO storage is accessible",
|
||||
"Check backend logs for detailed error information",
|
||||
"Ensure Temporal workers are running"
|
||||
]
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=500, detail=error_response)
|
||||
|
||||
finally:
|
||||
# Cleanup temporary file
|
||||
if temp_file_path and Path(temp_file_path).exists():
|
||||
try:
|
||||
Path(temp_file_path).unlink()
|
||||
logger.debug(f"Cleaned up temp file: {temp_file_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup temp file {temp_file_path}: {e}")
|
||||
|
||||
|
||||
@router.get("/{workflow_name}/parameters")
|
||||
async def get_workflow_parameters(
|
||||
workflow_name: str,
|
||||
|
||||
@@ -179,15 +179,8 @@ class TemporalManager:
|
||||
if not workflow_id:
|
||||
workflow_id = f"{workflow_name}-{str(uuid4())[:8]}"
|
||||
|
||||
# Prepare workflow input arguments in order
|
||||
# For security_assessment: (target_id, scanner_config, analyzer_config, reporter_config)
|
||||
# Prepare workflow input - target_id as first arg, rest as kwargs
|
||||
workflow_params = workflow_params or {}
|
||||
workflow_args = [
|
||||
target_id,
|
||||
workflow_params.get("scanner_config"),
|
||||
workflow_params.get("analyzer_config"),
|
||||
workflow_params.get("reporter_config")
|
||||
]
|
||||
|
||||
# Determine task queue from workflow vertical
|
||||
vertical = workflow_info.metadata.get("vertical", "default")
|
||||
@@ -199,10 +192,11 @@ class TemporalManager:
|
||||
)
|
||||
|
||||
try:
|
||||
# Start workflow execution with positional arguments
|
||||
# Start workflow execution with target_id + keyword arguments
|
||||
handle = await self.client.start_workflow(
|
||||
workflow=workflow_info.workflow_type, # Workflow class name
|
||||
args=workflow_args, # Positional arguments
|
||||
arg=target_id, # First positional argument
|
||||
kwargs=workflow_params, # Rest as keyword arguments
|
||||
id=workflow_id,
|
||||
task_queue=task_queue,
|
||||
retry_policy=RetryPolicy(
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Fuzzing modules for FuzzForge
|
||||
|
||||
This package contains fuzzing modules for different fuzzing engines.
|
||||
"""
|
||||
|
||||
from .atheris_fuzzer import AtherisFuzzer
|
||||
|
||||
__all__ = ["AtherisFuzzer"]
|
||||
@@ -0,0 +1,377 @@
|
||||
"""
|
||||
Atheris Fuzzer Module
|
||||
|
||||
Reusable module for fuzzing Python code using Atheris.
|
||||
Discovers and fuzzes user-provided Python targets with TestOneInput() function.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import importlib.util
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional, Callable
|
||||
import uuid
|
||||
|
||||
from modules.base import BaseModule, ModuleMetadata, ModuleResult, ModuleFinding
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AtherisFuzzer(BaseModule):
|
||||
"""
|
||||
Atheris fuzzing module - discovers and fuzzes Python code.
|
||||
|
||||
This module can be used by any workflow to fuzz Python targets.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.crashes = []
|
||||
self.total_executions = 0
|
||||
self.start_time = None
|
||||
self.last_stats_time = 0
|
||||
|
||||
def get_metadata(self) -> ModuleMetadata:
|
||||
"""Return module metadata"""
|
||||
return ModuleMetadata(
|
||||
name="atheris_fuzzer",
|
||||
version="1.0.0",
|
||||
description="Python fuzzing using Atheris - discovers and fuzzes TestOneInput() functions",
|
||||
author="FuzzForge Team",
|
||||
category="fuzzer",
|
||||
tags=["fuzzing", "atheris", "python", "coverage"],
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target_file": {
|
||||
"type": "string",
|
||||
"description": "Python file with TestOneInput() function (auto-discovered if not specified)"
|
||||
},
|
||||
"max_iterations": {
|
||||
"type": "integer",
|
||||
"description": "Maximum fuzzing iterations",
|
||||
"default": 100000
|
||||
},
|
||||
"timeout_seconds": {
|
||||
"type": "integer",
|
||||
"description": "Fuzzing timeout in seconds",
|
||||
"default": 300
|
||||
},
|
||||
"stats_callback": {
|
||||
"description": "Optional callback for real-time statistics"
|
||||
}
|
||||
}
|
||||
},
|
||||
requires_workspace=True
|
||||
)
|
||||
|
||||
def validate_config(self, config: Dict[str, Any]) -> bool:
|
||||
"""Validate fuzzing configuration"""
|
||||
max_iterations = config.get("max_iterations", 100000)
|
||||
if not isinstance(max_iterations, int) or max_iterations <= 0:
|
||||
raise ValueError(f"max_iterations must be positive integer, got: {max_iterations}")
|
||||
|
||||
timeout = config.get("timeout_seconds", 300)
|
||||
if not isinstance(timeout, int) or timeout <= 0:
|
||||
raise ValueError(f"timeout_seconds must be positive integer, got: {timeout}")
|
||||
|
||||
return True
|
||||
|
||||
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
|
||||
"""
|
||||
Execute Atheris fuzzing on user code.
|
||||
|
||||
Args:
|
||||
config: Fuzzing configuration
|
||||
workspace: Path to user's uploaded code
|
||||
|
||||
Returns:
|
||||
ModuleResult with crash findings
|
||||
"""
|
||||
self.start_timer()
|
||||
self.start_time = time.time()
|
||||
|
||||
# Validate configuration
|
||||
self.validate_config(config)
|
||||
self.validate_workspace(workspace)
|
||||
|
||||
# Extract config
|
||||
target_file = config.get("target_file")
|
||||
max_iterations = config.get("max_iterations", 100000)
|
||||
timeout_seconds = config.get("timeout_seconds", 300)
|
||||
stats_callback = config.get("stats_callback")
|
||||
|
||||
logger.info(
|
||||
f"Starting Atheris fuzzing (max_iterations={max_iterations}, "
|
||||
f"timeout={timeout_seconds}s, target={target_file or 'auto-discover'})"
|
||||
)
|
||||
|
||||
try:
|
||||
# Step 1: Discover or load target
|
||||
target_path = self._discover_target(workspace, target_file)
|
||||
logger.info(f"Using fuzz target: {target_path}")
|
||||
|
||||
# Step 2: Load target module
|
||||
test_one_input = self._load_target_module(target_path)
|
||||
logger.info(f"Loaded TestOneInput function from {target_path}")
|
||||
|
||||
# Step 3: Run fuzzing
|
||||
await self._run_fuzzing(
|
||||
test_one_input=test_one_input,
|
||||
target_path=target_path,
|
||||
max_iterations=max_iterations,
|
||||
timeout_seconds=timeout_seconds,
|
||||
stats_callback=stats_callback
|
||||
)
|
||||
|
||||
# Step 4: Generate findings from crashes
|
||||
findings = self._generate_findings(target_path)
|
||||
|
||||
logger.info(
|
||||
f"Fuzzing completed: {self.total_executions} executions, "
|
||||
f"{len(self.crashes)} crashes found"
|
||||
)
|
||||
|
||||
return self.create_result(
|
||||
findings=findings,
|
||||
status="success",
|
||||
summary={
|
||||
"total_executions": self.total_executions,
|
||||
"crashes_found": len(self.crashes),
|
||||
"execution_time": self.get_execution_time(),
|
||||
"target_file": str(target_path.relative_to(workspace))
|
||||
},
|
||||
metadata={
|
||||
"max_iterations": max_iterations,
|
||||
"timeout_seconds": timeout_seconds
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fuzzing failed: {e}", exc_info=True)
|
||||
return self.create_result(
|
||||
findings=[],
|
||||
status="failed",
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
def _discover_target(self, workspace: Path, target_file: Optional[str]) -> Path:
|
||||
"""
|
||||
Discover fuzz target in workspace.
|
||||
|
||||
Args:
|
||||
workspace: Path to workspace
|
||||
target_file: Explicit target file or None for auto-discovery
|
||||
|
||||
Returns:
|
||||
Path to target file
|
||||
"""
|
||||
if target_file:
|
||||
# Use specified target
|
||||
target_path = workspace / target_file
|
||||
if not target_path.exists():
|
||||
raise FileNotFoundError(f"Target file not found: {target_file}")
|
||||
return target_path
|
||||
|
||||
# Auto-discover: look for fuzz_*.py or *_fuzz.py
|
||||
logger.info("Auto-discovering fuzz targets...")
|
||||
|
||||
candidates = []
|
||||
# Use rglob for recursive search (searches all subdirectories)
|
||||
for pattern in ["fuzz_*.py", "*_fuzz.py", "fuzz_target.py"]:
|
||||
matches = list(workspace.rglob(pattern))
|
||||
candidates.extend(matches)
|
||||
|
||||
if not candidates:
|
||||
raise FileNotFoundError(
|
||||
"No fuzz targets found. Expected files matching: fuzz_*.py, *_fuzz.py, or fuzz_target.py"
|
||||
)
|
||||
|
||||
# Use first candidate
|
||||
target = candidates[0]
|
||||
if len(candidates) > 1:
|
||||
logger.warning(
|
||||
f"Multiple fuzz targets found: {[str(c) for c in candidates]}. "
|
||||
f"Using: {target.name}"
|
||||
)
|
||||
|
||||
return target
|
||||
|
||||
def _load_target_module(self, target_path: Path) -> Callable:
|
||||
"""
|
||||
Load target module and get TestOneInput function.
|
||||
|
||||
Args:
|
||||
target_path: Path to Python file with TestOneInput
|
||||
|
||||
Returns:
|
||||
TestOneInput function
|
||||
"""
|
||||
# Add target directory to sys.path
|
||||
target_dir = target_path.parent
|
||||
if str(target_dir) not in sys.path:
|
||||
sys.path.insert(0, str(target_dir))
|
||||
|
||||
# Load module dynamically
|
||||
module_name = target_path.stem
|
||||
spec = importlib.util.spec_from_file_location(module_name, target_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"Cannot load module from {target_path}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# Get TestOneInput function
|
||||
if not hasattr(module, "TestOneInput"):
|
||||
raise AttributeError(
|
||||
f"Module {module_name} does not have TestOneInput() function. "
|
||||
"Atheris requires a TestOneInput(data: bytes) function."
|
||||
)
|
||||
|
||||
return module.TestOneInput
|
||||
|
||||
async def _run_fuzzing(
|
||||
self,
|
||||
test_one_input: Callable,
|
||||
target_path: Path,
|
||||
max_iterations: int,
|
||||
timeout_seconds: int,
|
||||
stats_callback: Optional[Callable] = None
|
||||
):
|
||||
"""
|
||||
Run Atheris fuzzing with real-time monitoring.
|
||||
|
||||
Args:
|
||||
test_one_input: TestOneInput function to fuzz
|
||||
target_path: Path to target file
|
||||
max_iterations: Max iterations
|
||||
timeout_seconds: Timeout in seconds
|
||||
stats_callback: Optional callback for stats
|
||||
"""
|
||||
import atheris
|
||||
|
||||
self.crashes = []
|
||||
self.total_executions = 0
|
||||
corpus_size = 0
|
||||
|
||||
# Wrapper to track executions and crashes
|
||||
def fuzz_wrapper(data):
|
||||
self.total_executions += 1
|
||||
|
||||
try:
|
||||
test_one_input(data)
|
||||
except Exception as e:
|
||||
# Capture crash
|
||||
crash_info = {
|
||||
"input": data,
|
||||
"exception": e,
|
||||
"exception_type": type(e).__name__,
|
||||
"stack_trace": traceback.format_exc(),
|
||||
"execution": self.total_executions
|
||||
}
|
||||
self.crashes.append(crash_info)
|
||||
logger.warning(
|
||||
f"Crash found (execution {self.total_executions}): "
|
||||
f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
# Re-raise so Atheris detects it
|
||||
raise
|
||||
|
||||
# Configure Atheris
|
||||
atheris.Setup(
|
||||
[
|
||||
"atheris_fuzzer",
|
||||
f"-runs={max_iterations}",
|
||||
f"-max_total_time={timeout_seconds}",
|
||||
"-print_final_stats=1"
|
||||
],
|
||||
fuzz_wrapper
|
||||
)
|
||||
|
||||
logger.info(f"Starting Atheris fuzzer (max_runs={max_iterations}, timeout={timeout_seconds}s)...")
|
||||
|
||||
# Run fuzzing in a separate task with monitoring
|
||||
async def monitor_stats():
|
||||
"""Monitor and report stats every 5 seconds"""
|
||||
while True:
|
||||
await asyncio.sleep(5)
|
||||
|
||||
if stats_callback:
|
||||
elapsed = time.time() - self.start_time
|
||||
execs_per_sec = self.total_executions / elapsed if elapsed > 0 else 0
|
||||
|
||||
await stats_callback({
|
||||
"total_execs": self.total_executions,
|
||||
"execs_per_sec": execs_per_sec,
|
||||
"crashes": len(self.crashes),
|
||||
"corpus_size": corpus_size,
|
||||
"coverage": 0.0, # Atheris doesn't expose coverage easily
|
||||
"elapsed_time": int(elapsed)
|
||||
})
|
||||
|
||||
# Start monitoring task
|
||||
monitor_task = None
|
||||
if stats_callback:
|
||||
monitor_task = asyncio.create_task(monitor_stats())
|
||||
|
||||
try:
|
||||
# Run fuzzing (blocking)
|
||||
atheris.Fuzz()
|
||||
except SystemExit:
|
||||
# Atheris exits when done
|
||||
pass
|
||||
finally:
|
||||
# Stop monitoring
|
||||
if monitor_task:
|
||||
monitor_task.cancel()
|
||||
try:
|
||||
await monitor_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
def _generate_findings(self, target_path: Path) -> List[ModuleFinding]:
|
||||
"""
|
||||
Generate ModuleFinding objects from crashes.
|
||||
|
||||
Args:
|
||||
target_path: Path to target file
|
||||
|
||||
Returns:
|
||||
List of findings
|
||||
"""
|
||||
findings = []
|
||||
|
||||
for crash in self.crashes:
|
||||
# Encode crash input for storage
|
||||
crash_input_b64 = base64.b64encode(crash["input"]).decode()
|
||||
|
||||
finding = self.create_finding(
|
||||
title=f"Crash: {crash['exception_type']}",
|
||||
description=(
|
||||
f"Atheris found crash during fuzzing:\n"
|
||||
f"Exception: {crash['exception_type']}\n"
|
||||
f"Message: {str(crash['exception'])}\n"
|
||||
f"Execution: {crash['execution']}"
|
||||
),
|
||||
severity="critical",
|
||||
category="crash",
|
||||
file_path=str(target_path),
|
||||
metadata={
|
||||
"crash_input_base64": crash_input_b64,
|
||||
"crash_input_hex": crash["input"].hex(),
|
||||
"exception_type": crash["exception_type"],
|
||||
"stack_trace": crash["stack_trace"],
|
||||
"execution_number": crash["execution"]
|
||||
},
|
||||
recommendation=(
|
||||
"Review the crash stack trace and input to identify the vulnerability. "
|
||||
"The crash input is provided in base64 and hex formats for reproduction."
|
||||
)
|
||||
)
|
||||
findings.append(finding)
|
||||
|
||||
return findings
|
||||
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Atheris Fuzzing Workflow
|
||||
|
||||
Fuzzes user-provided Python code using Atheris.
|
||||
"""
|
||||
|
||||
from .workflow import AtherisFuzzingWorkflow
|
||||
|
||||
__all__ = ["AtherisFuzzingWorkflow"]
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Atheris Fuzzing Workflow Activities
|
||||
|
||||
Activities specific to the Atheris fuzzing workflow.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
from temporalio import activity
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Add toolbox to path for module imports
|
||||
sys.path.insert(0, '/app/toolbox')
|
||||
|
||||
|
||||
@activity.defn(name="fuzz_with_atheris")
|
||||
async def fuzz_activity(workspace_path: str, config: dict) -> dict:
|
||||
"""
|
||||
Fuzzing activity using the AtherisFuzzer module on user code.
|
||||
|
||||
This activity:
|
||||
1. Imports the reusable AtherisFuzzer module
|
||||
2. Sets up real-time stats callback
|
||||
3. Executes fuzzing on user's TestOneInput() function
|
||||
4. Returns findings as ModuleResult
|
||||
|
||||
Args:
|
||||
workspace_path: Path to the workspace directory (user's uploaded code)
|
||||
config: Fuzzer configuration (target_file, max_iterations, timeout_seconds)
|
||||
|
||||
Returns:
|
||||
Fuzzer results dictionary (findings, summary, metadata)
|
||||
"""
|
||||
logger.info(f"Activity: fuzz_with_atheris (workspace={workspace_path})")
|
||||
|
||||
try:
|
||||
# Import reusable AtherisFuzzer module
|
||||
from modules.fuzzer import AtherisFuzzer
|
||||
|
||||
workspace = Path(workspace_path)
|
||||
if not workspace.exists():
|
||||
raise FileNotFoundError(f"Workspace not found: {workspace_path}")
|
||||
|
||||
# Get activity info for real-time stats
|
||||
info = activity.info()
|
||||
run_id = info.workflow_id
|
||||
|
||||
# Define stats callback for real-time monitoring
|
||||
async def stats_callback(stats_data: Dict[str, Any]):
|
||||
"""Callback for live fuzzing statistics"""
|
||||
try:
|
||||
logger.info("LIVE_STATS", extra={
|
||||
"stats_type": "fuzzing_live_update",
|
||||
"workflow_type": "atheris_fuzzing",
|
||||
"run_id": run_id,
|
||||
"executions": stats_data.get("total_execs", 0),
|
||||
"executions_per_sec": stats_data.get("execs_per_sec", 0.0),
|
||||
"crashes": stats_data.get("crashes", 0),
|
||||
"corpus_size": stats_data.get("corpus_size", 0),
|
||||
"coverage": stats_data.get("coverage", 0.0),
|
||||
"elapsed_time": stats_data.get("elapsed_time", 0),
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Error in stats callback: {e}")
|
||||
|
||||
# Add stats callback to config
|
||||
config["stats_callback"] = stats_callback
|
||||
|
||||
# Execute the fuzzer module
|
||||
fuzzer = AtherisFuzzer()
|
||||
result = await fuzzer.execute(config, workspace)
|
||||
|
||||
logger.info(
|
||||
f"✓ Fuzzing completed: "
|
||||
f"{result.summary.get('total_executions', 0)} executions, "
|
||||
f"{result.summary.get('crashes_found', 0)} crashes"
|
||||
)
|
||||
|
||||
return result.dict()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fuzzing failed: {e}", exc_info=True)
|
||||
raise
|
||||
@@ -0,0 +1,76 @@
|
||||
name: atheris_fuzzing
|
||||
version: "1.0.0"
|
||||
vertical: python
|
||||
description: "Fuzz Python code using Atheris with real-time monitoring. Automatically discovers and fuzzes TestOneInput() functions in user code."
|
||||
author: "FuzzForge Team"
|
||||
category: "fuzzing"
|
||||
tags:
|
||||
- "fuzzing"
|
||||
- "atheris"
|
||||
- "python"
|
||||
- "coverage"
|
||||
- "security"
|
||||
|
||||
supported_volume_modes:
|
||||
- "ro"
|
||||
|
||||
default_volume_mode: "ro"
|
||||
default_target_path: "/workspace"
|
||||
|
||||
requirements:
|
||||
tools:
|
||||
- "atheris_fuzzer"
|
||||
resources:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
timeout: 3600
|
||||
|
||||
has_docker: false
|
||||
|
||||
default_parameters:
|
||||
target_file: null
|
||||
max_iterations: 100000
|
||||
timeout_seconds: 300
|
||||
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
target_file:
|
||||
type: string
|
||||
description: "Python file with TestOneInput() function (auto-discovered if not specified)"
|
||||
max_iterations:
|
||||
type: integer
|
||||
default: 100000
|
||||
description: "Maximum fuzzing iterations"
|
||||
timeout_seconds:
|
||||
type: integer
|
||||
default: 300
|
||||
description: "Fuzzing timeout in seconds (5 minutes)"
|
||||
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
findings:
|
||||
type: array
|
||||
description: "Crashes and vulnerabilities found during fuzzing"
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
severity:
|
||||
type: string
|
||||
category:
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
summary:
|
||||
type: object
|
||||
description: "Fuzzing execution summary"
|
||||
properties:
|
||||
total_executions:
|
||||
type: integer
|
||||
crashes_found:
|
||||
type: integer
|
||||
execution_time:
|
||||
type: number
|
||||
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Atheris Fuzzing Workflow - Temporal Version
|
||||
|
||||
Fuzzes user-provided Python code using Atheris with real-time monitoring.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from temporalio import workflow
|
||||
from temporalio.common import RetryPolicy
|
||||
|
||||
# Import for type hints (will be executed by worker)
|
||||
with workflow.unsafe.imports_passed_through():
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@workflow.defn
|
||||
class AtherisFuzzingWorkflow:
|
||||
"""
|
||||
Fuzz Python code using Atheris.
|
||||
|
||||
User workflow:
|
||||
1. User runs: ff workflow run atheris_fuzzing .
|
||||
2. CLI uploads project to MinIO
|
||||
3. Worker downloads project
|
||||
4. Worker fuzzes TestOneInput() function
|
||||
5. Crashes reported as findings
|
||||
"""
|
||||
|
||||
@workflow.run
|
||||
async def run(
|
||||
self,
|
||||
target_id: str, # MinIO UUID of uploaded user code
|
||||
target_file: Optional[str] = None, # Optional: specific file to fuzz
|
||||
max_iterations: int = 100000,
|
||||
timeout_seconds: int = 300
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Main workflow execution.
|
||||
|
||||
Args:
|
||||
target_id: UUID of the uploaded target in MinIO
|
||||
target_file: Optional specific Python file with TestOneInput() (auto-discovered if None)
|
||||
max_iterations: Maximum fuzzing iterations
|
||||
timeout_seconds: Fuzzing timeout in seconds
|
||||
|
||||
Returns:
|
||||
Dictionary containing findings and summary
|
||||
"""
|
||||
workflow_id = workflow.info().workflow_id
|
||||
|
||||
workflow.logger.info(
|
||||
f"Starting AtherisFuzzingWorkflow "
|
||||
f"(workflow_id={workflow_id}, target_id={target_id}, "
|
||||
f"target_file={target_file or 'auto-discover'}, max_iterations={max_iterations}, "
|
||||
f"timeout_seconds={timeout_seconds})"
|
||||
)
|
||||
|
||||
results = {
|
||||
"workflow_id": workflow_id,
|
||||
"target_id": target_id,
|
||||
"status": "running",
|
||||
"steps": []
|
||||
}
|
||||
|
||||
try:
|
||||
# Step 1: Download user's project from MinIO
|
||||
workflow.logger.info("Step 1: Downloading user code from MinIO")
|
||||
target_path = await workflow.execute_activity(
|
||||
"get_target",
|
||||
target_id,
|
||||
start_to_close_timeout=timedelta(minutes=5),
|
||||
retry_policy=RetryPolicy(
|
||||
initial_interval=timedelta(seconds=1),
|
||||
maximum_interval=timedelta(seconds=30),
|
||||
maximum_attempts=3
|
||||
)
|
||||
)
|
||||
results["steps"].append({
|
||||
"step": "download_target",
|
||||
"status": "success",
|
||||
"target_path": target_path
|
||||
})
|
||||
workflow.logger.info(f"✓ User code downloaded to: {target_path}")
|
||||
|
||||
# Step 2: Run Atheris fuzzing
|
||||
workflow.logger.info("Step 2: Running Atheris fuzzing")
|
||||
|
||||
# Use defaults if parameters are None
|
||||
max_iterations = max_iterations if max_iterations is not None else 100000
|
||||
timeout_seconds = timeout_seconds if timeout_seconds is not None else 300
|
||||
|
||||
fuzz_config = {
|
||||
"target_file": target_file,
|
||||
"max_iterations": max_iterations,
|
||||
"timeout_seconds": timeout_seconds
|
||||
}
|
||||
|
||||
fuzz_results = await workflow.execute_activity(
|
||||
"fuzz_with_atheris",
|
||||
args=[target_path, fuzz_config],
|
||||
start_to_close_timeout=timedelta(seconds=timeout_seconds + 60),
|
||||
retry_policy=RetryPolicy(
|
||||
initial_interval=timedelta(seconds=2),
|
||||
maximum_interval=timedelta(seconds=60),
|
||||
maximum_attempts=1 # Fuzzing shouldn't retry
|
||||
)
|
||||
)
|
||||
|
||||
results["steps"].append({
|
||||
"step": "fuzzing",
|
||||
"status": "success",
|
||||
"executions": fuzz_results.get("summary", {}).get("total_executions", 0),
|
||||
"crashes": fuzz_results.get("summary", {}).get("crashes_found", 0)
|
||||
})
|
||||
workflow.logger.info(
|
||||
f"✓ Fuzzing completed: "
|
||||
f"{fuzz_results.get('summary', {}).get('total_executions', 0)} executions, "
|
||||
f"{fuzz_results.get('summary', {}).get('crashes_found', 0)} crashes"
|
||||
)
|
||||
|
||||
# Step 3: Upload results to MinIO
|
||||
workflow.logger.info("Step 3: Uploading results")
|
||||
try:
|
||||
results_url = await workflow.execute_activity(
|
||||
"upload_results",
|
||||
args=[workflow_id, fuzz_results, "json"],
|
||||
start_to_close_timeout=timedelta(minutes=2)
|
||||
)
|
||||
results["results_url"] = results_url
|
||||
workflow.logger.info(f"✓ Results uploaded to: {results_url}")
|
||||
except Exception as e:
|
||||
workflow.logger.warning(f"Failed to upload results: {e}")
|
||||
results["results_url"] = None
|
||||
|
||||
# Step 4: Cleanup cache
|
||||
workflow.logger.info("Step 4: Cleaning up cache")
|
||||
try:
|
||||
await workflow.execute_activity(
|
||||
"cleanup_cache",
|
||||
target_path,
|
||||
start_to_close_timeout=timedelta(minutes=1)
|
||||
)
|
||||
workflow.logger.info("✓ Cache cleaned up")
|
||||
except Exception as e:
|
||||
workflow.logger.warning(f"Cache cleanup failed: {e}")
|
||||
|
||||
# Mark workflow as successful
|
||||
results["status"] = "success"
|
||||
results["findings"] = fuzz_results.get("findings", [])
|
||||
results["summary"] = fuzz_results.get("summary", {})
|
||||
workflow.logger.info(
|
||||
f"✓ Workflow completed successfully: {workflow_id} "
|
||||
f"({results['summary'].get('crashes_found', 0)} crashes found)"
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
workflow.logger.error(f"Workflow failed: {e}")
|
||||
results["status"] = "error"
|
||||
results["error"] = str(e)
|
||||
results["steps"].append({
|
||||
"step": "error",
|
||||
"status": "failed",
|
||||
"error": str(e)
|
||||
})
|
||||
raise
|
||||
Reference in New Issue
Block a user