Challenge Task API (#59)

This commit is contained in:
Riccardo Schirone
2025-02-06 12:09:43 +01:00
committed by GitHub
parent 9885795582
commit fdefa0fccb
9 changed files with 1120 additions and 51 deletions
+1
View File
@@ -15,6 +15,7 @@ dependencies = [
[project.scripts]
buttercup-msg-publisher = "buttercup.common.msg_publisher:main"
buttercup-challenge-task = "buttercup.common.challenge_task_cli:main"
[tool.hatch.build.targets.wheel]
@@ -0,0 +1,416 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, Any, Callable
from os import PathLike
import logging
import uuid
import subprocess
from buttercup.common.utils import create_tmp_dir, copyanything
from contextlib import contextmanager
from typing import Iterator
logger = logging.getLogger(__name__)
class ChallengeTaskError(Exception):
"""Base class for Challenge Task errors."""
pass
@dataclass
class CommandResult:
success: bool
error: bytes | None = None
output: bytes | None = None
@dataclass
class ChallengeTask:
"""Class to manage Challenge Tasks."""
read_only_task_dir: PathLike
project_name: str
python_path: PathLike = Path("python")
local_task_dir: PathLike | None = None
SRC_DIR = "src"
DIFF_DIR = "diff"
OSS_FUZZ_DIR = "fuzz-tooling"
MAX_COMMIT_RETRIES = 3
_helper_path: Path = field(init=False)
def __post_init__(self) -> None:
self.read_only_task_dir = Path(self.read_only_task_dir)
self.local_task_dir = Path(self.local_task_dir) if self.local_task_dir else None
self.python_path = Path(self.python_path)
self._check_dir_exists(self.read_only_task_dir)
if self.local_task_dir:
self._check_dir_exists(self.local_task_dir)
# Verify required directories exist
for directory in [self.SRC_DIR, self.OSS_FUZZ_DIR]:
if not (self.task_dir / directory).is_dir():
raise ChallengeTaskError(f"Missing required directory: {self.task_dir / directory}")
self._helper_path = Path("infra/helper.py")
if not (self.get_oss_fuzz_path() / self._helper_path).exists():
raise ChallengeTaskError(f"Missing required file: {self.get_oss_fuzz_path() / self._helper_path}")
self._check_python_path()
def _check_dir_exists(self, path: Path) -> None:
if not path.exists():
raise ChallengeTaskError(f"Missing required directory: {path}")
if not path.is_dir():
raise ChallengeTaskError(f"Required directory is not a directory: {path}")
def _find_first_dir(self, subpath: Path) -> Path | None:
first_elem = next((self.task_dir / subpath).iterdir(), None)
if first_elem is None:
return None
return first_elem.relative_to(self.task_dir)
def get_source_subpath(self) -> Path | None:
# TODO: "Review task structure and Challenge Task operations" Issue #74
return self._find_first_dir(self.SRC_DIR)
def get_diff_subpath(self) -> Path | None:
# TODO: "Review task structure and Challenge Task operations" Issue #74
return self._find_first_dir(self.DIFF_DIR)
def get_oss_fuzz_subpath(self) -> Path | None:
# TODO: "Review task structure and Challenge Task operations" Issue #74
return self._find_first_dir(self.OSS_FUZZ_DIR)
def _task_dir_compose_path(self, subpath_method: Callable[[], Path | None]) -> Path | None:
subpath = subpath_method()
if subpath is None:
return None
return self.task_dir / subpath
def get_source_path(self) -> Path | None:
return self._task_dir_compose_path(self.get_source_subpath)
def get_diff_path(self) -> Path | None:
return self._task_dir_compose_path(self.get_diff_subpath)
def get_oss_fuzz_path(self) -> Path | None:
return self._task_dir_compose_path(self.get_oss_fuzz_subpath)
def _check_python_path(self) -> None:
"""Check if the configured python_path is available in system PATH."""
try:
subprocess.run([self.python_path, "--version"], check=False, capture_output=True, text=True)
except Exception as e:
raise ChallengeTaskError(f"Python executable couldn't be run: {self.python_path}") from e
@property
def task_dir(self) -> Path:
if self.local_task_dir is None:
return Path(self.read_only_task_dir)
return Path(self.local_task_dir)
def read_write_decorator(func: Callable) -> Callable:
"""Decorator to check if the task is read-only."""
def wrapper(self, *args: Any, **kwargs: Any) -> Any:
if self.local_task_dir is None:
raise ChallengeTaskError("Challenge Task is read-only, cannot perform this operation")
return func(self, *args, **kwargs)
return wrapper
def _add_optional_arg(self, cmd: list[str], flag: str, arg: Any | None):
if arg is not None:
if isinstance(arg, bool):
if arg:
cmd.append(flag)
else:
cmd.append(flag)
cmd.append(str(arg))
def _get_helper_cmd(self, helper_cmd: str, *args: Any, **kwargs: Any) -> list[str]:
cmd = [str(self.python_path), str(self._helper_path), helper_cmd]
for key, value in kwargs.items():
if key == "e":
for k, v in value.items() if isinstance(value, dict) else {}:
cmd.append("-e")
cmd.append(f"{k}={v}")
else:
self._add_optional_arg(cmd, f"--{key}", value)
for arg in args:
if arg is not None:
if isinstance(arg, list):
cmd.extend(arg)
else:
cmd.append(arg)
return cmd
def _log_output_line(self, current_line: bytes, new_data: bytes) -> bytes:
current_line += new_data
line_to_print = b""
if b"\n" in current_line:
line_to_print = current_line[: current_line.index(b"\n")]
current_line = current_line[current_line.index(b"\n") + 1 :]
logger.debug(line_to_print.decode())
return current_line
def _run_helper_cmd(self, cmd: list[str]) -> CommandResult:
try:
logger.debug(f"Running command (cwd={self.task_dir / self.get_oss_fuzz_subpath()}): {' '.join(cmd)}")
process = subprocess.Popen( # noqa: S603
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=self.task_dir / self.get_oss_fuzz_subpath(),
)
# Poll process for new output until finished
stdout = b""
stderr = b""
current_output_line = b""
current_error_line = b""
while True:
stdout_line = process.stdout.readline() if process.stdout else b""
stderr_line = process.stderr.readline() if process.stderr else b""
if stdout_line:
current_output_line = self._log_output_line(current_output_line, stdout_line)
stdout += stdout_line
if stderr_line:
current_error_line = self._log_output_line(current_error_line, stderr_line)
stderr += stderr_line
# Break if process has finished and we've read all output
if not stdout_line and not stderr_line and process.poll() is not None:
break
returncode = process.wait()
return CommandResult(
success=returncode == 0,
error=stderr,
output=stdout,
)
except subprocess.CalledProcessError as e:
logger.error(f"Command failed (cwd={self.task_dir / self.get_oss_fuzz_subpath()}): {' '.join(cmd)}")
return CommandResult(
success=False, error=e.stderr if e.stderr else None, output=e.stdout if e.stdout else None
)
except Exception as e:
logger.exception(f"Command failed (cwd={self.task_dir / self.get_oss_fuzz_subpath()}): {' '.join(cmd)}")
return CommandResult(success=False, error=str(e), output=None)
@read_write_decorator
def build_image(
self,
*,
pull_latest_base_image: bool = False,
cache: bool | None = None,
architecture: str | None = None,
) -> CommandResult:
logger.info(
"Building image for project %s | pull_latest_base_image=%s | cache=%s | architecture=%s",
self.project_name,
pull_latest_base_image,
cache,
architecture,
)
kwargs = {
"pull": pull_latest_base_image,
"no-pull": not pull_latest_base_image,
"cache": cache,
"architecture": architecture,
}
cmd = self._get_helper_cmd(
"build_image",
self.project_name,
**kwargs,
)
return self._run_helper_cmd(cmd)
@read_write_decorator
def build_fuzzers(
self,
use_source_dir: bool = True,
*,
architecture: str | None = None,
engine: str | None = None,
sanitizer: str | None = None,
env: Dict[str, str] | None = None,
) -> CommandResult:
logger.info(
"Building fuzzers for project %s | architecture=%s | engine=%s | sanitizer=%s | env=%s | use_source_dir=%s",
self.project_name,
architecture,
engine,
sanitizer,
env,
use_source_dir,
)
cmd = self._get_helper_cmd(
"build_fuzzers",
self.project_name,
str((self.task_dir / self.get_source_subpath()).absolute()) if use_source_dir else None,
architecture=architecture,
engine=engine,
sanitizer=sanitizer,
e=env,
)
return self._run_helper_cmd(cmd)
@read_write_decorator
def build_fuzzers_with_cache(
self,
use_source_dir: bool = True,
*,
architecture: str | None = None,
engine: str | None = None,
sanitizer: str | None = None,
env: Dict[str, str] | None = None,
) -> CommandResult:
check_build_res = self.check_build(architecture=architecture, engine=engine, sanitizer=sanitizer, env=env)
if check_build_res.success:
logger.info("Build is up to date, skipping building fuzzers")
return check_build_res
return self.build_fuzzers(
use_source_dir=use_source_dir,
architecture=architecture,
engine=engine,
sanitizer=sanitizer,
env=env,
)
@read_write_decorator
def check_build(
self,
*,
architecture: str | None = None,
engine: str | None = None,
sanitizer: str | None = None,
env: Dict[str, str] | None = None,
) -> CommandResult:
logger.info(
"Checking build for project %s | architecture=%s | engine=%s | sanitizer=%s | env=%s",
self.project_name,
architecture,
engine,
sanitizer,
env,
)
cmd = self._get_helper_cmd(
"check_build",
self.project_name,
architecture=architecture,
engine=engine,
sanitizer=sanitizer,
e=env,
)
return self._run_helper_cmd(cmd)
@read_write_decorator
def reproduce_pov(
self,
fuzzer_name: str,
crash_path: Path,
fuzzer_args: list[str] | None = None,
*,
architecture: str | None = None,
env: Dict[str, str] | None = None,
) -> CommandResult:
logger.info(
"Reproducing POV for project %s | fuzzer_name=%s | crash_path=%s | fuzzer_args=%s | architecture=%s | env=%s",
self.project_name,
fuzzer_name,
crash_path,
fuzzer_args,
architecture,
env,
)
cmd = self._get_helper_cmd(
"reproduce",
self.project_name,
fuzzer_name,
str(crash_path.absolute()),
fuzzer_args,
architecture=architecture,
e=env,
)
return self._run_helper_cmd(cmd)
@contextmanager
def get_rw_copy(self, work_dir: PathLike | None = None, delete: bool = True) -> Iterator[ChallengeTask]:
"""Create a copy of this task in a new writable directory.
Returns a context manager that yields a new ChallengeTask instance pointing to the new copy.
Example:
with task.get_rw_copy() as local_task:
local_task.build_fuzzers()
"""
if self.local_task_dir is not None:
yield self
return
work_dir = Path(work_dir) if work_dir else None
with create_tmp_dir(work_dir, delete, prefix=self.task_dir.name + "-") as tmp_dir:
# Copy the entire task directory to the temporary location
logger.info(f"Copying task directory {self.task_dir} to {tmp_dir}")
copyanything(self.task_dir, tmp_dir, symlinks=True)
# Create a new ChallengeTask instance pointing to the copy
copied_task = ChallengeTask(
read_only_task_dir=self.read_only_task_dir,
project_name=self.project_name,
python_path=self.python_path,
local_task_dir=tmp_dir,
)
try:
yield copied_task
finally:
pass
def commit(self, suffix: str | None = None) -> None:
"""Commit the local task directory to a stable path.
This is useful to save the task state for later use and together with
the `get_rw_copy` context manager.
"""
if self.local_task_dir is None:
raise ChallengeTaskError("Challenge Task is read-only, cannot commit")
assert isinstance(self.local_task_dir, Path)
new_local_task_dir = None
max_retries = self.MAX_COMMIT_RETRIES if suffix is None else 1
for i in range(max_retries):
suffix = suffix if suffix is not None else str(uuid.uuid4())[:16]
new_name = f"{self.read_only_task_dir.name}-{suffix}"
try:
logger.info(f"Committing task {self.local_task_dir} to {new_name}")
new_local_task_dir = self.local_task_dir.rename(self.local_task_dir.parent / new_name)
logger.info(f"Committed task {self.local_task_dir} to {new_name}")
break
except OSError as e:
if i == max_retries - 1:
raise ChallengeTaskError("Failed to commit task") from e
logger.error(
f"Failed to commit task {self.local_task_dir} to {new_name}. Retrying with a random suffix..."
)
suffix = None
self.local_task_dir = new_local_task_dir
@@ -0,0 +1,126 @@
from pydantic_settings import BaseSettings, CliSubCommand, get_subcommand, CliImplicitFlag
from pydantic import BaseModel
from buttercup.common.challenge_task import ChallengeTask
from buttercup.common.logger import setup_logging
from pathlib import Path
from typing import Dict
class BuildImageCommand(BaseModel):
pull_latest_base_image: bool = False
cache: bool | None = None
architecture: str | None = None
class BuildFuzzersCommand(BaseModel):
architecture: str | None = None
engine: str | None = None
sanitizer: str | None = None
env: Dict[str, str] | None = None
use_cache: bool = True
class CheckBuildCommand(BaseModel):
architecture: str | None = None
engine: str | None = None
sanitizer: str | None = None
env: Dict[str, str] | None = None
class ReproducePovCommand(BaseModel):
fuzzer_name: str
crash_path: Path
fuzzer_args: list[str] | None = None
architecture: str | None = None
env: Dict[str, str] | None = None
class Settings(BaseSettings):
task_dir: Path
project_name: str
python_path: Path = Path("python")
rw: CliImplicitFlag[bool] = False
build_image: CliSubCommand[BuildImageCommand]
build_fuzzers: CliSubCommand[BuildFuzzersCommand]
check_build: CliSubCommand[CheckBuildCommand]
reproduce_pov: CliSubCommand[ReproducePovCommand]
class Config:
env_prefix = "BUTTERCUP_CHALLENGE_TASK_"
env_file = ".env"
cli_parse_args = True
nested_model_default_partial_update = True
env_nested_delimiter = "__"
extra = "allow"
def handle_subcommand(task: ChallengeTask, subcommand: BaseModel):
if isinstance(subcommand, BuildImageCommand):
return task.build_image(
pull_latest_base_image=subcommand.pull_latest_base_image,
cache=subcommand.cache,
architecture=subcommand.architecture,
)
elif isinstance(subcommand, BuildFuzzersCommand):
if subcommand.use_cache:
return task.build_fuzzers_with_cache(
architecture=subcommand.architecture,
engine=subcommand.engine,
sanitizer=subcommand.sanitizer,
env=subcommand.env,
)
else:
return task.build_fuzzers(
architecture=subcommand.architecture,
engine=subcommand.engine,
sanitizer=subcommand.sanitizer,
env=subcommand.env,
)
elif isinstance(subcommand, CheckBuildCommand):
return task.check_build(
architecture=subcommand.architecture,
engine=subcommand.engine,
sanitizer=subcommand.sanitizer,
env=subcommand.env,
)
elif isinstance(subcommand, ReproducePovCommand):
return task.reproduce_pov(
fuzzer_name=subcommand.fuzzer_name,
crash_path=subcommand.crash_path,
fuzzer_args=subcommand.fuzzer_args,
architecture=subcommand.architecture,
env=subcommand.env,
)
else:
raise ValueError(f"Unknown subcommand: {subcommand}")
def main():
settings = Settings()
logger = setup_logging(__name__, "DEBUG")
if settings.rw:
task = ChallengeTask(
read_only_task_dir=settings.task_dir,
project_name=settings.project_name,
python_path=settings.python_path,
local_task_dir=settings.task_dir,
logger=logger,
)
else:
task = ChallengeTask(
read_only_task_dir=settings.task_dir,
project_name=settings.project_name,
python_path=settings.python_path,
logger=logger,
)
subcommand = get_subcommand(settings)
with task.get_rw_copy(delete=False) as rw_task:
result = handle_subcommand(rw_task, subcommand)
print("Command result:", result.success)
if __name__ == "__main__":
main()
+36 -2
View File
@@ -1,12 +1,46 @@
import shutil
import errno
import tempfile
import contextlib
from contextlib import contextmanager
from typing import Iterator, Any
from tempfile import TemporaryDirectory
from buttercup.common.logger import setup_logging
from pathlib import Path
from os import PathLike
def copyanything(src, dst):
def copyanything(src: PathLike, dst: PathLike, **kwargs: Any) -> None:
"""Copy a file or directory to a destination.
This function will:
- Copy directories recursively
- Copy single files
- Handle existing destinations
"""
src, dst = Path(src), Path(dst)
try:
shutil.copytree(src, dst)
shutil.copytree(src, dst, dirs_exist_ok=True, **kwargs)
except OSError as exc: # python >2.5
if exc.errno in (errno.ENOTDIR, errno.EINVAL):
shutil.copy(src, dst)
else:
raise
@contextmanager
def create_tmp_dir(work_dir: Path | None, delete: bool = True, prefix: str | None = None) -> Iterator[Path]:
"""Create a temporary directory inside a working dir and either keep or
delete it after use."""
logger = setup_logging(__name__)
if work_dir:
work_dir.mkdir(parents=True, exist_ok=True)
if delete:
try:
with TemporaryDirectory(dir=work_dir, prefix=prefix) as tmp_dir:
yield Path(tmp_dir)
except PermissionError:
logger.warning("Issues while creating/deleting a temporary directory...")
else:
with contextlib.nullcontext(tempfile.mkdtemp(dir=work_dir, prefix=prefix)) as tmp_dir:
yield Path(tmp_dir)
+19
View File
@@ -0,0 +1,19 @@
import pytest
def pytest_addoption(parser):
parser.addoption("--runintegration", action="store_true", default=False, help="run integration tests")
def pytest_configure(config):
config.addinivalue_line("markers", "integration: mark test as an integration test")
def pytest_collection_modifyitems(config, items):
if config.getoption("--runintegration"):
# --runintegration given in cli: do not skip integration tests
return
skip_integration = pytest.mark.skip(reason="need --runintegration option to run")
for item in items:
if "integration" in item.keywords:
item.add_marker(skip_integration)
Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

+477
View File
@@ -0,0 +1,477 @@
from pathlib import Path
import pytest
from unittest.mock import Mock, patch
import subprocess
from buttercup.common.challenge_task import ChallengeTask, ChallengeTaskError
import logging
@pytest.fixture
def task_dir(tmp_path: Path) -> Path:
"""Create a mock challenge task directory structure."""
# Create the main directories
oss_fuzz = tmp_path / "fuzz-tooling" / "my-oss-fuzz"
source = tmp_path / "src" / "my-source"
diffs = tmp_path / "diff" / "my-diff"
oss_fuzz.mkdir(parents=True, exist_ok=True)
source.mkdir(parents=True, exist_ok=True)
diffs.mkdir(parents=True, exist_ok=True)
# Create some mock patch files
(diffs / "patch1.diff").write_text("mock patch 1")
(diffs / "patch2.diff").write_text("mock patch 2")
# Create a mock helper.py file
helper_path = oss_fuzz / "infra/helper.py"
helper_path.parent.mkdir(parents=True, exist_ok=True)
helper_path.write_text("import sys;\nsys.exit(0)\n")
# Create a mock test.txt file
(source / "test.txt").write_text("mock test content")
return tmp_path
@pytest.fixture
def challenge_task_readonly(task_dir: Path) -> ChallengeTask:
"""Create a mock challenge task for testing."""
return ChallengeTask(
read_only_task_dir=task_dir,
project_name="example_project",
)
@pytest.fixture
def challenge_task(task_dir: Path) -> ChallengeTask:
"""Create a mock challenge task for testing."""
return ChallengeTask(
read_only_task_dir=task_dir,
local_task_dir=task_dir,
project_name="example_project",
)
@pytest.fixture
def challenge_task_custom_python(task_dir: Path) -> ChallengeTask:
"""Create a mock challenge task with custom python path for testing."""
return ChallengeTask(
read_only_task_dir=task_dir,
local_task_dir=task_dir,
project_name="example_project",
python_path="/usr/bin/python3",
)
def get_mock_popen(returncode: int, stdout: list[bytes], stderr: list[bytes]):
with patch("subprocess.Popen") as mock_popen:
mock_process = Mock()
mock_process.returncode = returncode
# Create mock pipe for stdout that handles multiple readline() calls gracefully
class MockStd:
def __init__(self, lines: list[bytes]):
self.lines = lines
self.current_line = 0
def readline(self):
if self.current_line < len(self.lines):
line = self.lines[self.current_line]
self.current_line += 1
return line
return b"" # Return empty bytes when no more lines
mock_process.stdout = MockStd(stdout)
# Create mock pipe for stderr
mock_process.stderr = MockStd(stderr)
# Setup poll and wait behavior
mock_process.poll.side_effect = [None, None, returncode]
mock_process.wait.return_value = returncode
# Make Popen return our mock process
mock_popen.return_value = mock_process
yield mock_popen
@pytest.fixture
def mock_subprocess():
"""Mock subprocess calls for testing."""
yield from get_mock_popen(0, [b"output line 1\n", b"output line 2\n"], [b"error line 1\n", b"error line 2\n"])
@pytest.fixture
def mock_failed_subprocess():
"""Mock subprocess calls for testing."""
yield from get_mock_popen(1, [], [b"Build failed"])
def test_directory_structure(challenge_task: ChallengeTask):
"""Test that the challenge task correctly identifies its directory structure."""
assert challenge_task.get_oss_fuzz_subpath() == Path("fuzz-tooling") / "my-oss-fuzz"
assert challenge_task.get_source_subpath() == Path("src") / "my-source"
assert challenge_task.get_diff_subpath() == Path("diff") / "my-diff"
assert (challenge_task.task_dir / challenge_task.get_oss_fuzz_subpath()).is_dir()
assert (challenge_task.task_dir / challenge_task.get_source_subpath()).is_dir()
assert (challenge_task.task_dir / challenge_task.get_diff_subpath()).is_dir()
assert challenge_task.get_source_path() == challenge_task.task_dir / challenge_task.get_source_subpath()
assert challenge_task.get_diff_path() == challenge_task.task_dir / challenge_task.get_diff_subpath()
assert challenge_task.get_oss_fuzz_path() == challenge_task.task_dir / challenge_task.get_oss_fuzz_subpath()
def test_readonly_task(challenge_task_readonly: ChallengeTask):
"""Test that a readonly task raises an error when trying to build."""
with pytest.raises(ChallengeTaskError, match="Challenge Task is read-only, cannot perform this operation"):
challenge_task_readonly.build_image()
with pytest.raises(ChallengeTaskError, match="Challenge Task is read-only, cannot perform this operation"):
challenge_task_readonly.build_fuzzers(engine="libfuzzer", sanitizer="address")
with pytest.raises(ChallengeTaskError, match="Challenge Task is read-only, cannot perform this operation"):
challenge_task_readonly.reproduce_pov(fuzzer_name="fuzz_target", crash_path=Path("crash-sample"))
def test_build_image(challenge_task: ChallengeTask, mock_subprocess):
"""Test building the docker image for the project."""
result = challenge_task.build_image()
assert result.success is True
assert result.output is not None
assert result.output == b"output line 1\noutput line 2\n"
mock_subprocess.assert_called_once()
# Verify the command and working directory
args, kwargs = mock_subprocess.call_args
assert args[0] == ["python", "infra/helper.py", "build_image", "--no-pull", "example_project"]
assert kwargs["cwd"] == challenge_task.task_dir / challenge_task.get_oss_fuzz_subpath()
# Verify output was read
mock_process = mock_subprocess.return_value
assert mock_process.wait.called # Verify we waited for process completion
def test_build_fuzzers(challenge_task: ChallengeTask, mock_subprocess):
"""Test building fuzzers with different configurations."""
result = challenge_task.build_fuzzers(
engine="libfuzzer",
sanitizer="address",
)
assert result.success is True
mock_subprocess.assert_called_once()
args, kwargs = mock_subprocess.call_args
assert args[0][:-1] == [
"python",
"infra/helper.py",
"build_fuzzers",
"--engine",
"libfuzzer",
"--sanitizer",
"address",
"example_project",
]
assert args[0][-1].endswith(str(challenge_task.get_source_subpath()))
assert kwargs["cwd"] == challenge_task.task_dir / challenge_task.get_oss_fuzz_subpath()
def test_check_build(challenge_task: ChallengeTask, mock_subprocess):
"""Test checking the build status."""
result = challenge_task.check_build(engine="libfuzzer", sanitizer="address")
assert result.success is True
mock_subprocess.assert_called_once()
args, kwargs = mock_subprocess.call_args
assert args[0] == [
"python",
"infra/helper.py",
"check_build",
"--engine",
"libfuzzer",
"--sanitizer",
"address",
"example_project",
]
assert kwargs["cwd"] == challenge_task.task_dir / challenge_task.get_oss_fuzz_subpath()
def test_reproduce_pov(challenge_task: ChallengeTask, mock_subprocess):
"""Test reproducing a proof of vulnerability."""
pov_file = Path("crash-sample")
result = challenge_task.reproduce_pov(
fuzzer_name="fuzz_target",
crash_path=pov_file,
)
assert result.success is True
mock_subprocess.assert_called_once()
args, kwargs = mock_subprocess.call_args
assert args[0][:-1] == ["python", "infra/helper.py", "reproduce", "example_project", "fuzz_target"]
assert args[0][-1].endswith("/crash-sample")
assert kwargs["cwd"] == challenge_task.task_dir / challenge_task.get_oss_fuzz_subpath()
def test_failed_build_image(challenge_task: ChallengeTask, mock_failed_subprocess):
"""Test handling failed build image operation."""
result = challenge_task.build_image()
assert result.success is False
assert result.error == b"Build failed"
def test_invalid_task_dir():
"""Test handling of invalid task directory."""
with pytest.raises(ChallengeTaskError, match="Missing required directory: /nonexistent"):
ChallengeTask(
read_only_task_dir=Path("/nonexistent"),
project_name="example_project",
)
def test_missing_required_dirs(tmp_path: Path):
"""Test handling of missing required directories."""
# Create task dir without required subdirectories
task_dir = tmp_path / "task"
task_dir.mkdir()
with pytest.raises(ChallengeTaskError, match=f"Missing required directory: {task_dir / 'src'}"):
ChallengeTask(
read_only_task_dir=task_dir,
project_name="example_project",
)
def test_build_image_custom_python(challenge_task_custom_python: ChallengeTask, mock_subprocess):
"""Test building the docker image using custom python path."""
result = challenge_task_custom_python.build_image()
assert result.success is True
mock_subprocess.assert_called_once()
args, kwargs = mock_subprocess.call_args
assert args[0] == ["/usr/bin/python3", "infra/helper.py", "build_image", "--no-pull", "example_project"]
assert kwargs["cwd"] == challenge_task_custom_python.task_dir / challenge_task_custom_python.get_oss_fuzz_subpath()
def test_build_fuzzers_custom_python(challenge_task_custom_python: ChallengeTask, mock_subprocess):
"""Test building fuzzers with custom python path."""
result = challenge_task_custom_python.build_fuzzers(
engine="libfuzzer",
sanitizer="address",
)
assert result.success is True
mock_subprocess.assert_called_once()
args, kwargs = mock_subprocess.call_args
assert args[0][:-1] == [
"/usr/bin/python3",
"infra/helper.py",
"build_fuzzers",
"--engine",
"libfuzzer",
"--sanitizer",
"address",
"example_project",
]
assert args[0][-1].endswith(str(challenge_task_custom_python.get_source_subpath()))
assert kwargs["cwd"] == challenge_task_custom_python.task_dir / challenge_task_custom_python.get_oss_fuzz_subpath()
@pytest.fixture
def libjpeg_oss_fuzz_task(tmp_path: Path) -> ChallengeTask:
"""Create a challenge task using a real OSS-Fuzz repository."""
# Clone real oss-fuzz repo into temp dir
oss_fuzz_dir = tmp_path / "fuzz-tooling"
oss_fuzz_dir.mkdir(parents=True)
source_dir = tmp_path / "src"
source_dir.mkdir(parents=True)
subprocess.run(["git", "-C", str(oss_fuzz_dir), "clone", "https://github.com/google/oss-fuzz.git"], check=True)
# Restore libjpeg-turbo project directory to specific commit
subprocess.run(
[
"git",
"-C",
str(oss_fuzz_dir / "oss-fuzz"),
"checkout",
"a5d517924f758028f299d7c6cecf3b471503a202",
"--",
"projects/libjpeg-turbo",
],
check=True,
)
# Download libpng source code
libjpeg_url = "https://github.com/libjpeg-turbo/libjpeg-turbo"
# Checkout specific libjpeg commit for reproducibility
subprocess.run(["git", "-C", str(source_dir), "clone", libjpeg_url], check=True)
subprocess.run(
["git", "-C", str(source_dir / "libjpeg-turbo"), "checkout", "6d91e950c871103a11bac2f10c63bf998796c719"],
check=True,
)
return ChallengeTask(
read_only_task_dir=tmp_path,
local_task_dir=tmp_path,
project_name="libjpeg-turbo",
logger=logging.getLogger(__name__),
)
@pytest.fixture
def libjpeg_crash_testcase() -> Path:
"""Get libjpeg-turbo crash testcase"""
crash_file = Path(__file__).parent / "data" / "libjpeg-crash"
if not crash_file.exists():
raise FileNotFoundError(f"Crash file not found at {crash_file}")
return crash_file
@pytest.mark.integration
def test_real_build_workflow(libjpeg_oss_fuzz_task: ChallengeTask):
"""Test the full build workflow using actual OSS-Fuzz repository."""
# Build the base image
result = libjpeg_oss_fuzz_task.build_image(pull_latest_base_image=False)
assert result.success is True, f"Build image failed: {result.error}"
# Build the fuzzers
result = libjpeg_oss_fuzz_task.build_fuzzers(
engine="libfuzzer",
sanitizer="address",
architecture="x86_64",
)
assert result.success is True, f"Build fuzzers failed: {result.error}"
# Check the build
result = libjpeg_oss_fuzz_task.check_build(
engine="libfuzzer",
sanitizer="address",
architecture="x86_64",
)
assert result.success is True, f"Check build failed: {result.error}"
@pytest.mark.integration
def test_build_fuzzers_with_cache(libjpeg_oss_fuzz_task: ChallengeTask):
"""Test building fuzzers with cache."""
result = libjpeg_oss_fuzz_task.build_fuzzers_with_cache(
engine="libfuzzer",
sanitizer="address",
)
assert result.success is True
assert result.output is not None
assert "cp /src/libjpeg_turbo_fuzzer_seed_corpus.zip /out/" in result.output.decode()
result = libjpeg_oss_fuzz_task.build_fuzzers_with_cache(
engine="libfuzzer",
sanitizer="address",
)
assert result.success is True
assert result.output is not None
assert "Check build passed" in result.output.decode()
assert "cp /src/libjpeg_turbo_fuzzer_seed_corpus.zip /out/" not in result.output.decode()
@pytest.mark.integration
def test_real_reproduce_pov(libjpeg_oss_fuzz_task: ChallengeTask, libjpeg_crash_testcase: Path):
"""Test the reproduce POV workflow using actual OSS-Fuzz repository."""
# Reproduce the POV
libjpeg_oss_fuzz_task.build_image(pull_latest_base_image=False)
libjpeg_oss_fuzz_task.build_fuzzers(engine="libfuzzer", sanitizer="address")
result = libjpeg_oss_fuzz_task.reproduce_pov(
fuzzer_name="libjpeg_turbo_fuzzer",
crash_path=libjpeg_crash_testcase,
)
assert result.success is False, "Reproduce POV failed"
def test_copy_task(challenge_task_readonly: ChallengeTask, mock_subprocess):
"""Test copying a challenge task to a temporary directory."""
with patch.object(ChallengeTask, "_check_python_path"):
with challenge_task_readonly.get_rw_copy() as local_task:
# Verify the task directory is different
assert local_task.task_dir != challenge_task_readonly.task_dir
# Verify the file structure was copied
assert local_task.get_source_path().is_dir()
assert local_task.get_oss_fuzz_path().is_dir()
assert local_task.get_diff_path().is_dir()
# Verify file contents were copied
copied_file = local_task.get_source_path() / "test.txt"
assert copied_file.exists()
assert copied_file.read_text() == "mock test content"
# Verify we can modify the copy without affecting the original
new_content = "modified content"
copied_file.write_text(new_content)
assert copied_file.read_text() != (challenge_task_readonly.get_source_path() / "test.txt").read_text()
result = local_task.build_image()
assert result.success is True
assert result.output is not None
assert result.output == b"output line 1\noutput line 2\n"
mock_subprocess.assert_called_once()
# Verify the temporary directory was cleaned up
assert not local_task.task_dir.exists()
def test_commit_task(challenge_task_readonly: ChallengeTask, mock_subprocess):
"""Test committing a challenge task."""
with patch.object(ChallengeTask, "_check_python_path"):
with challenge_task_readonly.get_rw_copy() as local_task:
local_task.build_image()
local_task.build_fuzzers(engine="libfuzzer", sanitizer="address")
old_local_dir = local_task.task_dir
local_task.commit()
commited_local_dir = local_task.task_dir
assert commited_local_dir != old_local_dir
assert commited_local_dir.exists()
assert commited_local_dir.is_dir()
assert not old_local_dir.exists()
commited_task = ChallengeTask(
read_only_task_dir=commited_local_dir,
local_task_dir=commited_local_dir,
project_name="example_project",
)
assert commited_task.get_source_path().exists()
assert commited_task.get_oss_fuzz_path().exists()
assert commited_task.get_diff_path().exists()
with pytest.raises(ChallengeTaskError, match="Missing required directory"):
ChallengeTask(
read_only_task_dir=old_local_dir,
local_task_dir=old_local_dir,
project_name="example_project",
)
def test_commit_task_with_suffix(challenge_task_readonly: ChallengeTask, mock_subprocess, tmp_path: Path):
"""Test committing a challenge task with a suffix."""
with patch.object(ChallengeTask, "_check_python_path"):
with challenge_task_readonly.get_rw_copy(work_dir=tmp_path) as local_task:
local_task.commit(suffix="test")
with challenge_task_readonly.get_rw_copy(work_dir=tmp_path) as local_task:
with pytest.raises(ChallengeTaskError, match="Failed to commit task"):
local_task.commit(suffix="test")
def test_no_commit_task(challenge_task_readonly: ChallengeTask, mock_subprocess):
"""Test that a not-commited task cannot be accessed after the context manager is closed."""
with patch.object(ChallengeTask, "_check_python_path"):
with challenge_task_readonly.get_rw_copy() as local_task:
local_task.build_image()
local_task.build_fuzzers(engine="libfuzzer", sanitizer="address")
old_local_dir = local_task.task_dir
with pytest.raises(ChallengeTaskError, match="Missing required directory"):
ChallengeTask(
read_only_task_dir=old_local_dir,
local_task_dir=old_local_dir,
project_name="example_project",
)
@@ -1,22 +1,19 @@
from buttercup.common.queues import BuildConfiguration, QueueNames, GroupNames
from buttercup.common.oss_fuzz_tool import OSSFuzzTool, Conf
from buttercup.common.queues import QueueNames, GroupNames
from redis import Redis
import argparse
import tempfile
from buttercup.common.queues import RQItem, QueueFactory
from buttercup.common.datastructures.msg_pb2 import BuildRequest, BuildOutput
from buttercup.common.logger import setup_logging
import shutil
from buttercup.common.challenge_task import ChallengeTask
from pathlib import Path
import time
import uuid
import os
logger = setup_logging(__name__)
def main():
prsr = argparse.ArgumentParser("Builder bot")
prsr.add_argument("--wdir", default=tempfile.TemporaryDirectory())
prsr.add_argument("--wdir", default="/tmp/builder-bot")
prsr.add_argument("--python", default="python")
prsr.add_argument("--redis_url", default="redis://127.0.0.1:6379")
prsr.add_argument("--timer", default=1000, type=int)
@@ -34,47 +31,46 @@ def main():
seconds = float(args.timer) // 1000.0
while True:
rqit: RQItem = queue.pop()
rqit: RQItem[BuildRequest] = queue.pop()
if rqit is not None:
msg: BuildRequest = rqit.deserialized
msg = rqit.deserialized
logger.info(f"Received build request for {msg.package_name}")
ossfuzz_dir = msg.ossfuzz
dirid = str(uuid.uuid4())
target = ossfuzz_dir
source_path_output = msg.source_path
if not args.allow_caching:
wdirstr = args.wdir
if not isinstance(wdirstr, str):
wdirstr = args.wdir.name
target = os.path.join(wdirstr, f"ossfuzz-snapshot-{dirid}")
logger.info(f"Copying {ossfuzz_dir} to {target}")
shutil.copytree(ossfuzz_dir, target)
source_snapshot = os.path.join(wdirstr, f"source-snapshot-{dirid}-{msg.package_name}")
shutil.copytree(msg.source_path, source_snapshot)
source_path_output = source_snapshot
conf = BuildConfiguration(msg.package_name, msg.engine, msg.sanitizer, source_path_output)
logger.info(f"Building oss-fuzz project {msg.package_name}")
build_tool = OSSFuzzTool(Conf(target, args.python, args.allow_pull, args.base_image_url))
if not build_tool.build_fuzzer_with_cache(conf):
logger.error(f"Could not build fuzzer {msg.package_name}")
logger.info(f"Pushing build output for {msg.package_name}")
output_q.push(
BuildOutput(
package_name=msg.package_name,
engine=msg.engine,
sanitizer=msg.sanitizer,
output_ossfuzz_path=target,
source_path=source_path_output,
task_id=msg.task_id,
build_type=msg.build_type,
task_dir = Path(msg.source_path).parent.parent
if args.allow_caching:
origin_task = ChallengeTask(
task_dir,
msg.package_name,
python_path=args.python,
local_task_dir=task_dir,
)
)
logger.info(f"Acked build request for {msg.package_name}")
queue.ack_item(rqit.item_id)
else:
origin_task = ChallengeTask(
task_dir,
msg.package_name,
python_path=args.python,
)
with origin_task.get_rw_copy(work_dir=args.wdir, delete=False) as task:
res = task.build_fuzzers_with_cache(engine=msg.engine, sanitizer=msg.sanitizer)
if not res.success:
logger.error(f"Could not build fuzzer {msg.package_name}")
continue
task.commit()
logger.info(f"Pushing build output for {msg.package_name}")
output_q.push(
BuildOutput(
package_name=msg.package_name,
engine=msg.engine,
sanitizer=msg.sanitizer,
output_ossfuzz_path=str(task.get_oss_fuzz_path()),
source_path=str(task.get_source_path()),
task_id=msg.task_id,
build_type=msg.build_type,
)
)
logger.info(f"Acked build request for {msg.package_name}")
queue.ack_item(rqit.item_id)
logger.info(f"Sleeping {seconds} seconds")
time.sleep(seconds)
@@ -5,6 +5,7 @@ from pathlib import Path
from redis import Redis
from buttercup.common.queues import ReliableQueue, QueueFactory, RQItem, QueueNames, GroupNames
from buttercup.common.maps import HarnessWeights, BuildMap, BUILD_TYPES
from buttercup.common.challenge_task import ChallengeTask
from buttercup.common.datastructures.msg_pb2 import (
TaskReady,
Task,
@@ -58,9 +59,8 @@ class Scheduler:
(source for source in task.sources if source.source_type == SourceDetail.SourceType.SOURCE_TYPE_REPO), None
)
if repo_source is not None:
example_libpng_path = Path(f"/tasks_storage/{task.task_id}/src/example-libpng")
logger.info(f"Checking if {example_libpng_path} exists")
if example_libpng_path.is_dir():
challenge_task = ChallengeTask(self.tasks_storage_dir / task.task_id, "example-libpng")
if challenge_task.get_source_path().is_dir():
logger.info(f"Mocking task {task.task_id} / example-libpng")
return BuildRequest(
package_name="libpng",
@@ -71,7 +71,6 @@ class Scheduler:
task_id=task.task_id,
build_type=BUILD_TYPES.FUZZER,
)
logger.info(f"{example_libpng_path} does not exist")
raise RuntimeError(f"Couldn't handle task {task.task_id}")
@@ -89,6 +88,7 @@ class Scheduler:
logger.info(
f"Processing build output for {build_output.package_name}|{build_output.engine}|{build_output.sanitizer}|{build_output.output_ossfuzz_path}"
)
build_dir = Path(build_output.output_ossfuzz_path) / "build" / "out" / build_output.package_name
targets = get_fuzz_targets(build_dir)
logger.debug(f"Found {len(targets)} targets: {targets}")