From fdefa0fccb77aa24b86644191166307750e591fc Mon Sep 17 00:00:00 2001 From: Riccardo Schirone <562321+ret2libc@users.noreply.github.com> Date: Thu, 6 Feb 2025 12:09:43 +0100 Subject: [PATCH] Challenge Task API (#59) --- common/pyproject.toml | 1 + common/src/buttercup/common/challenge_task.py | 416 +++++++++++++++ .../buttercup/common/challenge_task_cli.py | 126 +++++ common/src/buttercup/common/utils.py | 38 +- common/tests/conftest.py | 19 + common/tests/data/libjpeg-crash | Bin 0 -> 104 bytes common/tests/test_challenge_task.py | 477 ++++++++++++++++++ .../buttercup/fuzzing_infra/builder_bot.py | 86 ++-- .../orchestrator/scheduler/scheduler.py | 8 +- 9 files changed, 1120 insertions(+), 51 deletions(-) create mode 100644 common/src/buttercup/common/challenge_task.py create mode 100644 common/src/buttercup/common/challenge_task_cli.py create mode 100644 common/tests/conftest.py create mode 100644 common/tests/data/libjpeg-crash create mode 100644 common/tests/test_challenge_task.py diff --git a/common/pyproject.toml b/common/pyproject.toml index da998a82..21e349f5 100644 --- a/common/pyproject.toml +++ b/common/pyproject.toml @@ -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] diff --git a/common/src/buttercup/common/challenge_task.py b/common/src/buttercup/common/challenge_task.py new file mode 100644 index 00000000..899ace9a --- /dev/null +++ b/common/src/buttercup/common/challenge_task.py @@ -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 diff --git a/common/src/buttercup/common/challenge_task_cli.py b/common/src/buttercup/common/challenge_task_cli.py new file mode 100644 index 00000000..0f22fcf9 --- /dev/null +++ b/common/src/buttercup/common/challenge_task_cli.py @@ -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() diff --git a/common/src/buttercup/common/utils.py b/common/src/buttercup/common/utils.py index 516da676..5f8c605c 100644 --- a/common/src/buttercup/common/utils.py +++ b/common/src/buttercup/common/utils.py @@ -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) diff --git a/common/tests/conftest.py b/common/tests/conftest.py new file mode 100644 index 00000000..99f49aed --- /dev/null +++ b/common/tests/conftest.py @@ -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) diff --git a/common/tests/data/libjpeg-crash b/common/tests/data/libjpeg-crash new file mode 100644 index 0000000000000000000000000000000000000000..793d07f5483efdc6aa3a740e808ea408eda83b46 GIT binary patch literal 104 zcmex=Pzs11A%&5Smz^uT);0ypT Ch7rU7 literal 0 HcmV?d00001 diff --git a/common/tests/test_challenge_task.py b/common/tests/test_challenge_task.py new file mode 100644 index 00000000..a506fa95 --- /dev/null +++ b/common/tests/test_challenge_task.py @@ -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", + ) diff --git a/fuzzer/src/buttercup/fuzzing_infra/builder_bot.py b/fuzzer/src/buttercup/fuzzing_infra/builder_bot.py index 8778d8df..a3c21846 100644 --- a/fuzzer/src/buttercup/fuzzing_infra/builder_bot.py +++ b/fuzzer/src/buttercup/fuzzing_infra/builder_bot.py @@ -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) diff --git a/orchestrator/src/buttercup/orchestrator/scheduler/scheduler.py b/orchestrator/src/buttercup/orchestrator/scheduler/scheduler.py index 3262d3df..13c94d06 100644 --- a/orchestrator/src/buttercup/orchestrator/scheduler/scheduler.py +++ b/orchestrator/src/buttercup/orchestrator/scheduler/scheduler.py @@ -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}")