mirror of
https://github.com/FuzzingLabs/fuzzforge_ai.git
synced 2026-06-03 18:48:01 +02:00
feat: FuzzForge AI - complete rewrite for OSS release
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
FROM localhost/fuzzforge-modules-sdk:0.1.0
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Rust toolchain
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
# Install Rust analysis tools (skipping cargo-geiger as it's heavy)
|
||||
# RUN cargo install cargo-geiger --locked || true
|
||||
RUN cargo install cargo-audit --locked || true
|
||||
|
||||
COPY ./src /app/src
|
||||
COPY ./pyproject.toml /app/pyproject.toml
|
||||
|
||||
# Remove workspace reference since we're using wheels
|
||||
RUN sed -i '/\[tool\.uv\.sources\]/,/^$/d' /app/pyproject.toml
|
||||
|
||||
RUN uv sync --find-links /wheels
|
||||
@@ -0,0 +1,45 @@
|
||||
PACKAGE=$(word 1, $(shell uv version))
|
||||
VERSION=$(word 2, $(shell uv version))
|
||||
|
||||
PODMAN?=/usr/bin/podman
|
||||
|
||||
SOURCES=./src
|
||||
TESTS=./tests
|
||||
|
||||
.PHONY: bandit build clean format mypy pytest ruff version
|
||||
|
||||
bandit:
|
||||
uv run bandit --recursive $(SOURCES)
|
||||
|
||||
build:
|
||||
$(PODMAN) build --file ./Dockerfile --no-cache --tag $(PACKAGE):$(VERSION)
|
||||
|
||||
save: build
|
||||
$(PODMAN) save --format oci-archive --output /tmp/$(PACKAGE)-$(VERSION).oci $(PACKAGE):$(VERSION)
|
||||
|
||||
clean:
|
||||
@find . -type d \( \
|
||||
-name '*.egg-info' \
|
||||
-o -name '.mypy_cache' \
|
||||
-o -name '.pytest_cache' \
|
||||
-o -name '.ruff_cache' \
|
||||
-o -name '__pycache__' \
|
||||
\) -printf 'removing directory %p\n' -exec rm -rf {} +
|
||||
|
||||
cloc:
|
||||
cloc $(SOURCES)
|
||||
|
||||
format:
|
||||
uv run ruff format $(SOURCES) $(TESTS)
|
||||
|
||||
mypy:
|
||||
uv run mypy $(SOURCES)
|
||||
|
||||
pytest:
|
||||
uv run pytest $(TESTS)
|
||||
|
||||
ruff:
|
||||
uv run ruff check --fix $(SOURCES) $(TESTS)
|
||||
|
||||
version:
|
||||
@echo '$(PACKAGE)@$(VERSION)'
|
||||
@@ -0,0 +1,46 @@
|
||||
# FuzzForge Modules - FIXME
|
||||
|
||||
## Installation
|
||||
|
||||
### Python
|
||||
|
||||
```shell
|
||||
# install the package (users)
|
||||
uv sync
|
||||
# install the package and all development dependencies (developers)
|
||||
uv sync --all-extras
|
||||
```
|
||||
|
||||
### Container
|
||||
|
||||
```shell
|
||||
# build the image
|
||||
make build
|
||||
# run the container
|
||||
mkdir -p "${PWD}/data" "${PWD}/data/input" "${PWD}/data/output"
|
||||
echo '{"settings":{},"resources":[]}' > "${PWD}/data/input/input.json"
|
||||
podman run --rm \
|
||||
--volume "${PWD}/data:/data" \
|
||||
'<name>:<version>' 'uv run module'
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```shell
|
||||
uv run module
|
||||
```
|
||||
|
||||
## Development tools
|
||||
|
||||
```shell
|
||||
# run ruff (formatter)
|
||||
make format
|
||||
# run mypy (type checker)
|
||||
make mypy
|
||||
# run tests (pytest)
|
||||
make pytest
|
||||
# run ruff (linter)
|
||||
make ruff
|
||||
```
|
||||
|
||||
See the file `Makefile` at the root of this directory for more tools.
|
||||
@@ -0,0 +1,6 @@
|
||||
[mypy]
|
||||
plugins = pydantic.mypy
|
||||
strict = True
|
||||
warn_unused_ignores = True
|
||||
warn_redundant_casts = True
|
||||
warn_return_any = True
|
||||
@@ -0,0 +1,28 @@
|
||||
[project]
|
||||
name = "rust-analyzer"
|
||||
version = "0.0.1"
|
||||
description = "FIXME"
|
||||
authors = []
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"fuzzforge-modules-sdk==0.0.1",
|
||||
"pydantic==2.12.4",
|
||||
"structlog==25.5.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
lints = [
|
||||
"bandit==1.8.6",
|
||||
"mypy==1.18.2",
|
||||
"ruff==0.14.4",
|
||||
]
|
||||
tests = [
|
||||
"pytest==9.0.2",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
module = "module.__main__:main"
|
||||
|
||||
[tool.uv]
|
||||
package = true
|
||||
@@ -0,0 +1,19 @@
|
||||
line-length = 120
|
||||
|
||||
[lint]
|
||||
select = [ "ALL" ]
|
||||
ignore = [
|
||||
"COM812", # conflicts with the formatter
|
||||
"D100", # ignoring missing docstrings in public modules
|
||||
"D104", # ignoring missing docstrings in public packages
|
||||
"D203", # conflicts with 'D211'
|
||||
"D213", # conflicts with 'D212'
|
||||
"TD002", # ignoring missing author in 'TODO' statements
|
||||
"TD003", # ignoring missing issue link in 'TODO' statements
|
||||
]
|
||||
|
||||
[lint.per-file-ignores]
|
||||
"tests/*" = [
|
||||
"PLR2004", # allowing comparisons using unamed numerical constants in tests
|
||||
"S101", # allowing 'assert' statements in tests
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fuzzforge_modules_sdk.api import logs
|
||||
|
||||
from module.mod import Module
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fuzzforge_modules_sdk.api.modules.base import FuzzForgeModule
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""TODO."""
|
||||
logs.configure()
|
||||
module: FuzzForgeModule = Module()
|
||||
module.main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Rust Analyzer module for FuzzForge.
|
||||
|
||||
This module analyzes Rust source code to identify fuzzable entry points,
|
||||
unsafe blocks, and known vulnerabilities.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fuzzforge_modules_sdk.api.constants import PATH_TO_OUTPUTS
|
||||
from fuzzforge_modules_sdk.api.models import FuzzForgeModuleResults
|
||||
from fuzzforge_modules_sdk.api.modules.base import FuzzForgeModule
|
||||
|
||||
from module.models import AnalysisResult, EntryPoint, Input, Output, UnsafeBlock, Vulnerability
|
||||
from module.settings import Settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fuzzforge_modules_sdk.api.models import FuzzForgeModuleResource
|
||||
|
||||
|
||||
class Module(FuzzForgeModule):
|
||||
"""Rust Analyzer module - analyzes Rust code for fuzzable entry points."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize an instance of the class."""
|
||||
name: str = "rust-analyzer"
|
||||
version: str = "0.1.0"
|
||||
FuzzForgeModule.__init__(self, name=name, version=version)
|
||||
self._project_path: Path | None = None
|
||||
self._settings: Settings | None = None
|
||||
|
||||
@classmethod
|
||||
def _get_input_type(cls) -> type[Input]:
|
||||
"""Return the input type."""
|
||||
return Input
|
||||
|
||||
@classmethod
|
||||
def _get_output_type(cls) -> type[Output]:
|
||||
"""Return the output type."""
|
||||
return Output
|
||||
|
||||
def _prepare(self, settings: Settings) -> None: # type: ignore[override]
|
||||
"""Prepare the module.
|
||||
|
||||
:param settings: Module settings.
|
||||
|
||||
"""
|
||||
self._settings = settings
|
||||
|
||||
def _find_cargo_toml(self, resources: list[FuzzForgeModuleResource]) -> Path | None:
|
||||
"""Find the Cargo.toml file in the resources.
|
||||
|
||||
:param resources: List of input resources.
|
||||
:returns: Path to Cargo.toml or None.
|
||||
|
||||
"""
|
||||
for resource in resources:
|
||||
if resource.path.name == "Cargo.toml":
|
||||
return resource.path
|
||||
# Check if resource is a directory containing Cargo.toml
|
||||
cargo_path = resource.path / "Cargo.toml"
|
||||
if cargo_path.exists():
|
||||
return cargo_path
|
||||
return None
|
||||
|
||||
def _parse_cargo_toml(self, cargo_path: Path) -> tuple[str, str, str]:
|
||||
"""Parse Cargo.toml to extract crate name, version, and lib name.
|
||||
|
||||
:param cargo_path: Path to Cargo.toml.
|
||||
:returns: Tuple of (crate_name, version, lib_name).
|
||||
|
||||
"""
|
||||
import tomllib
|
||||
|
||||
with cargo_path.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
|
||||
package = data.get("package", {})
|
||||
crate_name = package.get("name", "unknown")
|
||||
version = package.get("version", "0.0.0")
|
||||
|
||||
# Get lib name - defaults to crate name with dashes converted to underscores
|
||||
lib_section = data.get("lib", {})
|
||||
lib_name = lib_section.get("name", crate_name.replace("-", "_"))
|
||||
|
||||
return crate_name, version, lib_name
|
||||
|
||||
def _find_entry_points(self, project_path: Path) -> list[EntryPoint]:
|
||||
"""Find fuzzable entry points in the Rust source.
|
||||
|
||||
:param project_path: Path to the Rust project.
|
||||
:returns: List of entry points.
|
||||
|
||||
"""
|
||||
entry_points: list[EntryPoint] = []
|
||||
|
||||
# Patterns for fuzzable functions (take &[u8], &str, or impl Read)
|
||||
fuzzable_patterns = [
|
||||
r"pub\s+fn\s+(\w+)\s*\([^)]*&\[u8\][^)]*\)",
|
||||
r"pub\s+fn\s+(\w+)\s*\([^)]*&str[^)]*\)",
|
||||
r"pub\s+fn\s+(\w+)\s*\([^)]*impl\s+Read[^)]*\)",
|
||||
r"pub\s+fn\s+(\w+)\s*\([^)]*data:\s*&\[u8\][^)]*\)",
|
||||
r"pub\s+fn\s+(\w+)\s*\([^)]*input:\s*&\[u8\][^)]*\)",
|
||||
r"pub\s+fn\s+(\w+)\s*\([^)]*buf:\s*&\[u8\][^)]*\)",
|
||||
]
|
||||
|
||||
# Also find parse/decode functions
|
||||
parser_patterns = [
|
||||
r"pub\s+fn\s+(parse\w*)\s*\([^)]*\)",
|
||||
r"pub\s+fn\s+(decode\w*)\s*\([^)]*\)",
|
||||
r"pub\s+fn\s+(deserialize\w*)\s*\([^)]*\)",
|
||||
r"pub\s+fn\s+(from_bytes\w*)\s*\([^)]*\)",
|
||||
r"pub\s+fn\s+(read\w*)\s*\([^)]*\)",
|
||||
]
|
||||
|
||||
src_path = project_path / "src"
|
||||
if not src_path.exists():
|
||||
src_path = project_path
|
||||
|
||||
for rust_file in src_path.rglob("*.rs"):
|
||||
try:
|
||||
content = rust_file.read_text()
|
||||
lines = content.split("\n")
|
||||
|
||||
for line_num, line in enumerate(lines, 1):
|
||||
# Check fuzzable patterns
|
||||
for pattern in fuzzable_patterns:
|
||||
match = re.search(pattern, line)
|
||||
if match:
|
||||
entry_points.append(
|
||||
EntryPoint(
|
||||
function=match.group(1),
|
||||
file=str(rust_file.relative_to(project_path)),
|
||||
line=line_num,
|
||||
signature=line.strip(),
|
||||
fuzzable=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Check parser patterns (may need manual review)
|
||||
for pattern in parser_patterns:
|
||||
match = re.search(pattern, line)
|
||||
if match:
|
||||
# Avoid duplicates
|
||||
func_name = match.group(1)
|
||||
if not any(ep.function == func_name for ep in entry_points):
|
||||
entry_points.append(
|
||||
EntryPoint(
|
||||
function=func_name,
|
||||
file=str(rust_file.relative_to(project_path)),
|
||||
line=line_num,
|
||||
signature=line.strip(),
|
||||
fuzzable=True,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return entry_points
|
||||
|
||||
def _find_unsafe_blocks(self, project_path: Path) -> list[UnsafeBlock]:
|
||||
"""Find unsafe blocks in the Rust source.
|
||||
|
||||
:param project_path: Path to the Rust project.
|
||||
:returns: List of unsafe blocks.
|
||||
|
||||
"""
|
||||
unsafe_blocks: list[UnsafeBlock] = []
|
||||
|
||||
src_path = project_path / "src"
|
||||
if not src_path.exists():
|
||||
src_path = project_path
|
||||
|
||||
for rust_file in src_path.rglob("*.rs"):
|
||||
try:
|
||||
content = rust_file.read_text()
|
||||
lines = content.split("\n")
|
||||
|
||||
for line_num, line in enumerate(lines, 1):
|
||||
if "unsafe" in line and ("{" in line or "fn" in line):
|
||||
# Determine context
|
||||
context = "unsafe block"
|
||||
if "unsafe fn" in line:
|
||||
context = "unsafe function"
|
||||
elif "unsafe impl" in line:
|
||||
context = "unsafe impl"
|
||||
elif "*const" in line or "*mut" in line:
|
||||
context = "raw pointer operation"
|
||||
|
||||
unsafe_blocks.append(
|
||||
UnsafeBlock(
|
||||
file=str(rust_file.relative_to(project_path)),
|
||||
line=line_num,
|
||||
context=context,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return unsafe_blocks
|
||||
|
||||
def _run_cargo_audit(self, project_path: Path) -> list[Vulnerability]:
|
||||
"""Run cargo-audit to find known vulnerabilities.
|
||||
|
||||
:param project_path: Path to the Rust project.
|
||||
:returns: List of vulnerabilities.
|
||||
|
||||
"""
|
||||
vulnerabilities: list[Vulnerability] = []
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["cargo", "audit", "--json"],
|
||||
cwd=project_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if result.stdout:
|
||||
audit_data = json.loads(result.stdout)
|
||||
for vuln in audit_data.get("vulnerabilities", {}).get("list", []):
|
||||
advisory = vuln.get("advisory", {})
|
||||
vulnerabilities.append(
|
||||
Vulnerability(
|
||||
advisory_id=advisory.get("id", "UNKNOWN"),
|
||||
crate_name=vuln.get("package", {}).get("name", "unknown"),
|
||||
version=vuln.get("package", {}).get("version", "0.0.0"),
|
||||
title=advisory.get("title", "Unknown vulnerability"),
|
||||
severity=advisory.get("severity", "unknown"),
|
||||
)
|
||||
)
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
return vulnerabilities
|
||||
|
||||
def _run(self, resources: list[FuzzForgeModuleResource]) -> FuzzForgeModuleResults:
|
||||
"""Run the analysis.
|
||||
|
||||
:param resources: Input resources.
|
||||
:returns: Module result status.
|
||||
|
||||
"""
|
||||
# Find the Rust project
|
||||
cargo_path = self._find_cargo_toml(resources)
|
||||
if cargo_path is None:
|
||||
self.get_logger().error("No Cargo.toml found in resources")
|
||||
return FuzzForgeModuleResults.FAILURE
|
||||
|
||||
project_path = cargo_path.parent
|
||||
self._project_path = project_path
|
||||
|
||||
self.get_logger().info("Analyzing Rust project", project=str(project_path))
|
||||
|
||||
# Parse Cargo.toml
|
||||
crate_name, crate_version, lib_name = self._parse_cargo_toml(cargo_path)
|
||||
self.get_logger().info("Found crate", name=crate_name, version=crate_version, lib_name=lib_name)
|
||||
|
||||
# Find entry points
|
||||
entry_points = self._find_entry_points(project_path)
|
||||
self.get_logger().info("Found entry points", count=len(entry_points))
|
||||
|
||||
# Find unsafe blocks
|
||||
unsafe_blocks = self._find_unsafe_blocks(project_path)
|
||||
self.get_logger().info("Found unsafe blocks", count=len(unsafe_blocks))
|
||||
|
||||
# Run cargo-audit if enabled
|
||||
vulnerabilities: list[Vulnerability] = []
|
||||
if self._settings and self._settings.run_audit:
|
||||
vulnerabilities = self._run_cargo_audit(project_path)
|
||||
self.get_logger().info("Found vulnerabilities", count=len(vulnerabilities))
|
||||
|
||||
# Build result
|
||||
analysis = AnalysisResult(
|
||||
crate_name=crate_name,
|
||||
crate_version=crate_version,
|
||||
lib_name=lib_name,
|
||||
entry_points=entry_points,
|
||||
unsafe_blocks=unsafe_blocks,
|
||||
vulnerabilities=vulnerabilities,
|
||||
summary={
|
||||
"entry_points": len(entry_points),
|
||||
"unsafe_blocks": len(unsafe_blocks),
|
||||
"vulnerabilities": len(vulnerabilities),
|
||||
},
|
||||
)
|
||||
|
||||
# Set output data for results.json
|
||||
self.set_output(
|
||||
analysis=analysis.model_dump(),
|
||||
)
|
||||
|
||||
# Write analysis to output file (for backwards compatibility)
|
||||
output_path = PATH_TO_OUTPUTS / "analysis.json"
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(analysis.model_dump_json(indent=2))
|
||||
|
||||
self.get_logger().info("Analysis complete", output=str(output_path))
|
||||
|
||||
return FuzzForgeModuleResults.SUCCESS
|
||||
|
||||
def _cleanup(self, settings: Settings) -> None: # type: ignore[override]
|
||||
"""Clean up after execution.
|
||||
|
||||
:param settings: Module settings.
|
||||
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Models for rust-analyzer module."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fuzzforge_modules_sdk.api.models import FuzzForgeModuleInputBase, FuzzForgeModuleOutputBase
|
||||
|
||||
from module.settings import Settings
|
||||
|
||||
|
||||
class Input(FuzzForgeModuleInputBase[Settings]):
|
||||
"""Input for the rust-analyzer module."""
|
||||
|
||||
|
||||
class EntryPoint(BaseModel):
|
||||
"""A fuzzable entry point in the Rust codebase."""
|
||||
|
||||
#: Function name.
|
||||
function: str
|
||||
|
||||
#: Source file path.
|
||||
file: str
|
||||
|
||||
#: Line number.
|
||||
line: int
|
||||
|
||||
#: Function signature.
|
||||
signature: str
|
||||
|
||||
#: Whether the function takes &[u8] or similar fuzzable input.
|
||||
fuzzable: bool = True
|
||||
|
||||
|
||||
class UnsafeBlock(BaseModel):
|
||||
"""An unsafe block detected in the codebase."""
|
||||
|
||||
#: Source file path.
|
||||
file: str
|
||||
|
||||
#: Line number.
|
||||
line: int
|
||||
|
||||
#: Context description.
|
||||
context: str
|
||||
|
||||
|
||||
class Vulnerability(BaseModel):
|
||||
"""A known vulnerability from cargo-audit."""
|
||||
|
||||
#: Advisory ID (e.g., RUSTSEC-2021-0001).
|
||||
advisory_id: str
|
||||
|
||||
#: Affected crate name.
|
||||
crate_name: str
|
||||
|
||||
#: Affected version.
|
||||
version: str
|
||||
|
||||
#: Vulnerability title.
|
||||
title: str
|
||||
|
||||
#: Severity level.
|
||||
severity: str
|
||||
|
||||
|
||||
class AnalysisResult(BaseModel):
|
||||
"""The complete analysis result."""
|
||||
|
||||
#: Crate name from Cargo.toml (use this in fuzz/Cargo.toml dependencies).
|
||||
crate_name: str
|
||||
|
||||
#: Crate version.
|
||||
crate_version: str
|
||||
|
||||
#: Library name for use in Rust code (use in `use` statements).
|
||||
#: In Rust, dashes become underscores: "fuzz-demo" -> "fuzz_demo".
|
||||
lib_name: str = ""
|
||||
|
||||
#: List of fuzzable entry points.
|
||||
entry_points: list[EntryPoint]
|
||||
|
||||
#: List of unsafe blocks.
|
||||
unsafe_blocks: list[UnsafeBlock]
|
||||
|
||||
#: List of known vulnerabilities.
|
||||
vulnerabilities: list[Vulnerability]
|
||||
|
||||
#: Summary statistics.
|
||||
summary: dict[str, int]
|
||||
|
||||
|
||||
class Output(FuzzForgeModuleOutputBase):
|
||||
"""Output for the rust-analyzer module."""
|
||||
|
||||
#: The analysis result (as dict for serialization).
|
||||
analysis: dict | None = None
|
||||
|
||||
#: Path to the analysis JSON file.
|
||||
analysis_file: Path | None = None
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Settings for rust-analyzer module."""
|
||||
|
||||
from fuzzforge_modules_sdk.api.models import FuzzForgeModulesSettingsBase
|
||||
|
||||
|
||||
class Settings(FuzzForgeModulesSettingsBase):
|
||||
"""Settings for the rust-analyzer module."""
|
||||
|
||||
#: Whether to run cargo-audit for CVE detection.
|
||||
run_audit: bool = True
|
||||
|
||||
#: Whether to run cargo-geiger for unsafe detection.
|
||||
run_geiger: bool = True
|
||||
|
||||
#: Maximum depth for dependency analysis.
|
||||
max_depth: int = 3
|
||||
Reference in New Issue
Block a user