Initial commit

This commit is contained in:
Tanguy Duhamel
2025-09-29 21:26:41 +02:00
parent ecf8d49dde
commit 0547b78429
208 changed files with 72069 additions and 53 deletions
+173
View File
@@ -0,0 +1,173 @@
# Contributing to FuzzForge 🤝
Thank you for your interest in contributing to FuzzForge! We welcome contributions from the community and are excited to collaborate with you.
## 🌟 Ways to Contribute
- 🐛 **Bug Reports** - Help us identify and fix issues
- 💡 **Feature Requests** - Suggest new capabilities and improvements
- 🔧 **Code Contributions** - Submit bug fixes, features, and enhancements
- 📚 **Documentation** - Improve guides, tutorials, and API documentation
- 🧪 **Testing** - Help test new features and report issues
- 🛡️ **Security Workflows** - Contribute new security analysis workflows
## 📋 Contribution Guidelines
### Code Style
- Follow [PEP 8](https://pep8.org/) for Python code
- Use type hints where applicable
- Write clear, descriptive commit messages
- Include docstrings for all public functions and classes
- Add tests for new functionality
### Commit Message Format
We use conventional commits for clear history:
```
<type>(<scope>): <description>
[optional body]
[optional footer]
```
**Types:**
- `feat:` New feature
- `fix:` Bug fix
- `docs:` Documentation changes
- `style:` Code formatting (no logic changes)
- `refactor:` Code restructuring without changing functionality
- `test:` Adding or updating tests
- `chore:` Maintenance tasks
**Examples:**
```
feat(workflows): add new static analysis workflow for Go
fix(api): resolve authentication timeout issue
docs(readme): update installation instructions
```
### Pull Request Process
1. **Create a Branch**
```bash
git checkout -b feature/your-feature-name
# or
git checkout -b fix/issue-description
```
2. **Make Your Changes**
- Write clean, well-documented code
- Add tests for new functionality
- Update documentation as needed
3. **Test Your Changes**
```bash
# Test workflows
cd test_projects/vulnerable_app/
ff workflow security_assessment .
```
4. **Submit Pull Request**
- Use a clear, descriptive title
- Provide detailed description of changes
- Link related issues using `Fixes #123` or `Closes #123`
- Ensure all CI checks pass
## 🛡️ Security Workflow Development
### Creating New Workflows
1. **Workflow Structure**
```
backend/toolbox/workflows/your_workflow/
├── __init__.py
├── workflow.py # Main Prefect flow
├── metadata.yaml # Workflow metadata
└── Dockerfile # Container definition
```
2. **Register Your Workflow**
Add your workflow to `backend/toolbox/workflows/registry.py`:
```python
# Import your workflow
from .your_workflow.workflow import main_flow as your_workflow_flow
# Add to registry
WORKFLOW_REGISTRY["your_workflow"] = {
"flow": your_workflow_flow,
"module_path": "toolbox.workflows.your_workflow.workflow",
"function_name": "main_flow",
"description": "Description of your workflow",
"version": "1.0.0",
"author": "Your Name",
"tags": ["tag1", "tag2"]
}
```
3. **Testing Workflows**
- Create test cases in `test_projects/vulnerable_app/`
- Ensure SARIF output format compliance
- Test with various input scenarios
### Security Guidelines
- 🔐 Never commit secrets, API keys, or credentials
- 🛡️ Focus on **defensive security** tools and analysis
- ⚠️ Do not create tools for malicious purposes
- 🧪 Test workflows thoroughly before submission
- 📋 Follow responsible disclosure for security issues
## 🐛 Bug Reports
When reporting bugs, please include:
- **Environment**: OS, Python version, Docker version
- **Steps to Reproduce**: Clear steps to recreate the issue
- **Expected Behavior**: What should happen
- **Actual Behavior**: What actually happens
- **Logs**: Relevant error messages and stack traces
- **Screenshots**: If applicable
Use our [Bug Report Template](.github/ISSUE_TEMPLATE/bug_report.md).
## 💡 Feature Requests
For new features, please provide:
- **Use Case**: Why is this feature needed?
- **Proposed Solution**: How should it work?
- **Alternatives**: Other approaches considered
- **Implementation**: Technical considerations (optional)
Use our [Feature Request Template](.github/ISSUE_TEMPLATE/feature_request.md).
## 📚 Documentation
Help improve our documentation:
- **API Documentation**: Update docstrings and type hints
- **User Guides**: Create tutorials and how-to guides
- **Workflow Documentation**: Document new security workflows
- **Examples**: Add practical usage examples
## 🙏 Recognition
Contributors will be:
- Listed in our [Contributors](CONTRIBUTORS.md) file
- Mentioned in release notes for significant contributions
- Invited to join our Discord community
- Eligible for FuzzingLabs Academy courses and swag
## 📜 License
By contributing to FuzzForge, you agree that your contributions will be licensed under the same [Business Source License 1.1](LICENSE) as the project.
---
**Thank you for making FuzzForge better! 🚀**
Every contribution, no matter how small, helps build a stronger security community.
+61
View File
@@ -0,0 +1,61 @@
License text copyright (c) 2025 FuzzingLabs, All Rights Reserved.
"Business Source License" is a trademark of MariaDB Corporation Ab.
Parameters
Licensor: FuzzingLabs
Licensed Work: FuzzForge version 0.6.0 or later. The Licensed Work is (c) 2025 FuzzingLabs.
Additional Use Grant: You may make non-production use of the Licensed Work, including
research, academic, educational, personal, or internal evaluation purposes.
Production use of the Licensed Work requires a commercial license from FuzzingLabs.
Change Date: Four years from the date the Licensed Work is published.
Change License: Apache License, Version 2.0
For information about alternative licensing arrangements for the Licensed Work,
please contact licensing@fuzzinglabs.com.
Notice
Business Source License 1.1
Terms
The Licensor hereby grants you the right to copy, modify, create derivative
works, redistribute, and make non-production use of the Licensed Work. The
Licensor may make an Additional Use Grant, above, permitting limited production use.
Effective on the Change Date, or the fourth anniversary of the first publicly
available distribution of a specific version of the Licensed Work under this
License, whichever comes first, the Licensor hereby grants you rights under
the terms of the Change License, and the rights granted in the paragraph
above terminate.
If your use of the Licensed Work does not comply with the requirements
currently in effect as described in this License, you must purchase a
commercial license from the Licensor, its affiliated entities, or authorized
resellers, or you must refrain from using the Licensed Work.
All copies of the original and modified Licensed Work, and derivative works
of the Licensed Work, are subject to this License. This License applies
separately for each version of the Licensed Work and the Change Date may vary
for each version of the Licensed Work released by Licensor.
You must conspicuously display this License on each original or modified copy
of the Licensed Work. If you receive the Licensed Work in original or
modified form from a third party, the terms and conditions set forth in this
License apply to your use of that work.
Any use of the Licensed Work in violation of this License will automatically
terminate your rights under this License for the current and all other
versions of the Licensed Work.
This License does not grant you any right in any trademark or logo of
Licensor or its affiliates (provided that you may use a trademark or logo of
Licensor as expressly required by this License).
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
TITLE.
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a cross-
claim or counterclaim in a lawsuit) alleging that the Work or a
Contribution incorporated within the Work constitutes direct or
contributory patent infringement, then any patent licenses granted
to You under this License for that Work shall terminate as of the
date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or Derivative
Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+12
View File
@@ -0,0 +1,12 @@
FuzzForge
Copyright (c) 2025 FuzzingLabs
This product includes software developed by FuzzingLabs (https://fuzzforge.ai).
Licensed under the Business Source License 1.1 (BSL).
After the Change Date (four years from the date of publication), this version
of the Licensed Work will be made available under the Apache License, Version 2.0.
You may not use the name "FuzzingLabs" or "FuzzForge" nor the names of its
contributors to endorse or promote products derived from this software
without specific prior written permission.
+95 -53
View File
@@ -1,13 +1,28 @@
# FuzzForge
<p align="center">
<img src="docs/static/img/fuzzforge_white.png" alt="FuzzForge Banner" width="20%">
</p>
<h1 align="center">FuzzForge 🚧</h1>
![FuzzForge Logo](docs/assets/fuzzforge-logo.png)
<p align="center"><strong>AI-powered workflow automation and AI Agents for AppSec, Fuzzing & Offensive Security</strong></p>
**AI-powered workflow automation and AI Agents for AppSec, Fuzzing & Offensive Security**
<p align="center">
<a href="https://discord.com/invite/acqv9FVG"><img src="https://img.shields.io/discord/1420767905255133267?logo=discord&label=Discord" alt="Discord"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-BSL%20%2B%20Apache-orange" alt="License: BSL + Apache"></a>
<a href="https://www.python.org/downloads/"><img src="https://img.shields.io/badge/python-3.11%2B-blue" alt="Python 3.11+"/></a>
<a href="https://fuzzforge.ai"><img src="https://img.shields.io/badge/Website-fuzzforge.ai-blue?logo=vercel" alt="Website"/></a>
<img src="https://img.shields.io/badge/version-0.6.0-green" alt="Version">
</p>
[![Discord](https://img.shields.io/discord/0000000000000?logo=discord&label=Discord&color=7289da)](https://discord.com/invite/acqv9FVG)
[![Website](https://img.shields.io/badge/Website-fuzzforge.ai-blue?logo=vercel)](https://fuzzforge.ai)
[![License](https://img.shields.io/badge/license-BSL%20%2B%20Apache-orange)](LICENSE)
![Version](https://img.shields.io/badge/version-0.6.0-green)
<p align="center">
<sub>
<a href="#-overview"><b>Overview</b></a>
• <a href="#-key-features"><b>Features</b></a>
• <a href="#-installation"><b>Installation</b></a>
• <a href="#-quickstart"><b>Quickstart</b></a>
• <a href="#ai-powered-workflow-execution"><b>Demo</b></a>
• <a href="#-contributing"><b>Contributing</b></a>
</sub>
</p>
---
@@ -22,51 +37,7 @@
FuzzForge is **open source**, built to empower security teams, researchers, and the community.
---
## ⚡ Quickstart
Run your first workflow in **3 steps**:
```bash
# 1. Clone the repo
git clone https://github.com/fuzzinglabs/fuzzforge.git
cd fuzzforge
# 2. Build & run with Docker
docker compose up
# 3. Access the UI
open http://localhost:3000
```
👉 More installation options in the [Documentation](https://fuzzforge.ai/docs).
---
## 🔍 Example Workflow
Example: Run a workflow that audits an Android APK with AI agents:
```bash
fuzzforge run workflows/android_apk_audit.yaml
```
FuzzForge automatically orchestrates static analysis, AI-assisted reversing, and vulnerability triage.
---
## 🎥 Demos
### AI-Powered Workflow Execution
![LLM Workflow Demo](docs/static/videos/llm_workflow.gif)
*AI agents automatically analyzing code and providing security insights*
### Manual Workflow Setup
![Manual Workflow Demo](docs/static/videos/manual_workflow.gif)
*Setting up and running security workflows through the interface*
> 🚧 FuzzForge is still a work in progress, you can [subscribe]() to get the latest news.
---
@@ -81,12 +52,83 @@ FuzzForge automatically orchestrates static analysis, AI-assisted reversing, and
---
## 📦 Installation
### Requirements
**Python 3.11+**
Python 3.11 or higher is required.
**uv Package Manager**
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
**Docker**
For containerized workflows, see the [Docker Installation Guide](https://docs.docker.com/get-docker/).
### CLI Installation
After installing the requirements, install the FuzzForge CLI:
```bash
# Clone the repository
git clone https://github.com/fuzzinglabs/fuzzforge_ai.git
cd fuzzforge_ai
# Install CLI with uv (from the root directory)
uv tool install --python python3.12 .
```
---
## ⚡ Quickstart
Run your first workflow in **3 steps**:
```bash
# 1. Clone the repo
git clone https://github.com/fuzzinglabs/fuzzforge.git
cd fuzzforge
# 2. Build & run with Docker
# Set registry host for your OS (local registry is mandatory)
# macOS/Windows (Docker Desktop):
export REGISTRY_HOST=host.docker.internal
# Linux (default):
# export REGISTRY_HOST=localhost
docker compose up -d
```
> The first launch can take 5-10 minutes due to Docker image building - a good time for a coffee break ☕
```bash
# 3. Run your first workflow
cd test_projects/vulnerable_app/ # Go into the test directory
fuzzforge init # Init a fuzzforge project
ff workflow security_assessment . # Start a workflow (you can also use ff command)
```
### Manual Workflow Setup
![Manual Workflow Demo](docs/static/videos/manual_workflow.gif)
*Setting up and running security workflows through the interface*
👉 More installation options in the [Documentation](https://fuzzforge.ai/docs).
---
## AI-Powered Workflow Execution
![LLM Workflow Demo](docs/static/videos/llm_workflow.gif)
*AI agents automatically analyzing code and providing security insights*
## 📚 Resources
- 🌐 [Website](https://fuzzforge.ai)
- 📖 [Documentation](https://fuzzforge.ai/docs)
- 💬 [Community Discord](https://discord.com/invite/acqv9FVG)
- 🎓 [FuzzingLabs Academy](https://academy.fuzzinglabs.com)
- 🎓 [FuzzingLabs Academy](https://academy.fuzzinglabs.com/?coupon=GITHUB_FUZZFORGE)
---
+6
View File
@@ -0,0 +1,6 @@
.env
__pycache__/
*.pyc
fuzzforge_sessions.db
agentops.log
*.log
+110
View File
@@ -0,0 +1,110 @@
# FuzzForge AI Module
FuzzForge AI is the multi-agent layer that lets you operate the FuzzForge security platform through natural language. It orchestrates local tooling, registered Agent-to-Agent (A2A) peers, and the Prefect-powered backend while keeping long-running context in memory and project knowledge graphs.
## Quick Start
1. **Initialise a project**
```bash
cd /path/to/project
fuzzforge init
```
2. **Review environment settings** copy `.fuzzforge/.env.template` to `.fuzzforge/.env`, then edit the values to match your provider. The template ships with commented defaults for OpenAI-style usage and placeholders for Cognee keys.
```env
LLM_PROVIDER=openai
LITELLM_MODEL=gpt-5-mini
OPENAI_API_KEY=sk-your-key
FUZZFORGE_MCP_URL=http://localhost:8010/mcp
SESSION_PERSISTENCE=sqlite
```
Optional flags you may want to enable early:
```env
MEMORY_SERVICE=inmemory
AGENTOPS_API_KEY=sk-your-agentops-key # Enable hosted tracing
LOG_LEVEL=INFO # CLI / server log level
```
3. **Populate the knowledge graph**
```bash
fuzzforge ingest --path . --recursive
# alias: fuzzforge rag ingest --path . --recursive
```
4. **Launch the agent shell**
```bash
fuzzforge ai agent
```
Keep the backend running (Prefect API at `FUZZFORGE_MCP_URL`) so workflow commands succeed.
## Everyday Workflow
- Run `fuzzforge ai agent` and start with `list available fuzzforge workflows` or `/memory status` to confirm everything is wired.
- Use natural prompts for automation (`run fuzzforge workflow …`, `search project knowledge for …`) and fall back to slash commands for precision (`/recall`, `/sendfile`).
- Keep `/memory datasets` handy to see which Cognee datasets are available after each ingest.
- Start the HTTP surface with `python -m fuzzforge_ai` when external agents need access to artifacts or graph queries. The CLI stays usable at the same time.
- Refresh the knowledge graph regularly: `fuzzforge ingest --path . --recursive --force` keeps responses aligned with recent code changes.
## What the Agent Can Do
- **Route requests** automatically selects the right local tool or remote agent using the A2A capability registry.
- **Run security workflows** list, submit, and monitor FuzzForge workflows via MCP wrappers.
- **Manage artifacts** create downloadable files for reports, code edits, and shared attachments.
- **Maintain context** stores session history, semantic recall, and Cognee project graphs.
- **Serve over HTTP** expose the same agent as an A2A server using `python -m fuzzforge_ai`.
## Essential Commands
Inside `fuzzforge ai agent` you can mix slash commands and free-form prompts:
```text
/list # Show registered A2A agents
/register http://:10201 # Add a remote agent
/artifacts # List generated files
/sendfile SecurityAgent src/report.md "Please review"
You> route_to SecurityAnalyzer: scan ./backend for secrets
You> run fuzzforge workflow static_analysis_scan on ./test_projects/demo
You> search project knowledge for "prefect status" using INSIGHTS
```
Artifacts created during the conversation are served from `.fuzzforge/artifacts/` and exposed through the A2A HTTP API.
## Memory & Knowledge
The module layers three storage systems:
- **Session persistence** (SQLite or in-memory) for chat transcripts.
- **Semantic recall** via the ADK memory service for fuzzy search.
- **Cognee graphs** for project-wide knowledge built from ingestion runs.
Re-run ingestion after major code changes to keep graph answers relevant. If Cognee variables are not set, graph-specific tools automatically respond with a polite "not configured" message.
## Sample Prompts
Use these to validate the setup once the agent shell is running:
- `list available fuzzforge workflows`
- `run fuzzforge workflow static_analysis_scan on ./backend with target_branch=main`
- `show findings for that run once it finishes`
- `refresh the project knowledge graph for ./backend`
- `search project knowledge for "prefect readiness" using INSIGHTS`
- `/recall terraform secrets`
- `/memory status`
- `ROUTE_TO SecurityAnalyzer: audit infrastructure_vulnerable`
## Need More Detail?
Dive into the dedicated guides under `ai/docs/advanced/`:
- [Architecture](https://docs.fuzzforge.ai/docs/ai/intro) High-level architecture with diagrams and component breakdowns.
- [Ingestion](https://docs.fuzzforge.ai/docs/ai/ingestion.md) Command options, Cognee persistence, and prompt examples.
- [Configuration](https://docs.fuzzforge.ai/docs/ai/configuration.md) LLM provider matrix, local model setup, and tracing options.
- [Prompts](https://docs.fuzzforge.ai/docs/ai/prompts.md) Slash commands, workflow prompts, and routing tips.
- [A2A Services](https://docs.fuzzforge.ai/docs/ai/a2a-services.md) HTTP endpoints, agent card, and collaboration flow.
- [Memory Persistence](https://docs.fuzzforge.ai/docs/ai/architecture.md#memory--persistence) Deep dive on memory storage, datasets, and how `/memory status` inspects them.
## Development Notes
- Entry point for the CLI: `ai/src/fuzzforge_ai/cli.py`
- A2A HTTP server: `ai/src/fuzzforge_ai/a2a_server.py`
- Tool routing & workflow glue: `ai/src/fuzzforge_ai/agent_executor.py`
- Ingestion helpers: `ai/src/fuzzforge_ai/ingest_utils.py`
Install the module in editable mode (`pip install -e ai`) while iterating so CLI changes are picked up immediately.
+93
View File
@@ -0,0 +1,93 @@
FuzzForge AI LLM Configuration Guide
===================================
This note summarises the environment variables and libraries that drive LiteLLM (via the Google ADK runtime) inside the FuzzForge AI module. For complete matrices and advanced examples, read `docs/advanced/configuration.md`.
Core Libraries
--------------
- `google-adk` hosts the agent runtime, memory services, and LiteLLM bridge.
- `litellm` provider-agnostic LLM client used by ADK and the executor.
- Provider SDKs install the SDK that matches your target backend (`openai`, `anthropic`, `google-cloud-aiplatform`, `groq`, etc.).
- Optional extras: `agentops` for tracing, `cognee[all]` for knowledge-graph ingestion, `ollama` CLI for running local models.
Quick install foundation::
```
pip install google-adk litellm openai
```
Add any provider-specific SDKs (for example `pip install anthropic groq`) on top of that base.
Baseline Setup
--------------
Copy `.fuzzforge/.env.template` to `.fuzzforge/.env` and set the core fields:
```
LLM_PROVIDER=openai
LITELLM_MODEL=gpt-5-mini
OPENAI_API_KEY=sk-your-key
FUZZFORGE_MCP_URL=http://localhost:8010/mcp
SESSION_PERSISTENCE=sqlite
MEMORY_SERVICE=inmemory
```
LiteLLM Provider Examples
-------------------------
OpenAI-compatible (Azure, etc.)::
```
LLM_PROVIDER=azure_openai
LITELLM_MODEL=gpt-4o-mini
LLM_API_KEY=sk-your-azure-key
LLM_ENDPOINT=https://your-resource.openai.azure.com
```
Anthropic::
```
LLM_PROVIDER=anthropic
LITELLM_MODEL=claude-3-haiku-20240307
ANTHROPIC_API_KEY=sk-your-key
```
Ollama (local)::
```
LLM_PROVIDER=ollama_chat
LITELLM_MODEL=codellama:latest
OLLAMA_API_BASE=http://localhost:11434
```
Run `ollama pull codellama:latest` so the adapter can respond immediately.
Vertex AI::
```
LLM_PROVIDER=vertex_ai
LITELLM_MODEL=gemini-1.5-pro
GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
```
Provider Checklist
------------------
- **OpenAI / Azure OpenAI**: `LLM_PROVIDER`, `LITELLM_MODEL`, API key, optional endpoint + API version (Azure).
- **Anthropic**: `LLM_PROVIDER=anthropic`, `LITELLM_MODEL`, `ANTHROPIC_API_KEY`.
- **Google Vertex AI**: `LLM_PROVIDER=vertex_ai`, `LITELLM_MODEL`, `GOOGLE_APPLICATION_CREDENTIALS`, `GOOGLE_CLOUD_PROJECT`.
- **Groq**: `LLM_PROVIDER=groq`, `LITELLM_MODEL`, `GROQ_API_KEY`.
- **Ollama / Local**: `LLM_PROVIDER=ollama_chat`, `LITELLM_MODEL`, `OLLAMA_API_BASE`, and the model pulled locally (`ollama pull <model>`).
Knowledge Graph Add-ons
-----------------------
Set these only if you plan to use Cognee project graphs:
```
LLM_COGNEE_PROVIDER=openai
LLM_COGNEE_MODEL=gpt-5-mini
LLM_COGNEE_API_KEY=sk-your-key
```
Tracing & Debugging
-------------------
- Provide `AGENTOPS_API_KEY` to enable hosted traces for every conversation.
- Set `FUZZFORGE_DEBUG=1` (and optionally `LOG_LEVEL=DEBUG`) for verbose executor output.
- Restart the agent after changing environment variables; LiteLLM loads configuration on boot.
Further Reading
---------------
`docs/advanced/configuration.md` provider comparison, debugging flags, and referenced modules.
+44
View File
@@ -0,0 +1,44 @@
[project]
name = "fuzzforge-ai"
version = "0.6.0"
description = "FuzzForge AI orchestration module"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"google-adk",
"a2a-sdk",
"litellm",
"python-dotenv",
"httpx",
"uvicorn",
"rich",
"agentops",
"fastmcp",
"mcp",
"typing-extensions",
"cognee>=0.3.0",
]
[project.optional-dependencies]
dev = [
"pytest",
"pytest-asyncio",
"black",
"ruff",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/fuzzforge_ai"]
[tool.hatch.metadata]
allow-direct-references = true
[tool.uv]
dev-dependencies = [
"pytest",
"pytest-asyncio",
]
+24
View File
@@ -0,0 +1,24 @@
"""
FuzzForge AI Module - Agent-to-Agent orchestration system
This module integrates the fuzzforge_ai components into FuzzForge,
providing intelligent AI agent capabilities for security analysis.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
__version__ = "0.6.0"
from .agent import FuzzForgeAgent
from .config_manager import ConfigManager
__all__ = ['FuzzForgeAgent', 'ConfigManager']
+109
View File
@@ -0,0 +1,109 @@
"""
FuzzForge A2A Server
Run this to expose FuzzForge as an A2A-compatible agent
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import os
import warnings
import logging
from dotenv import load_dotenv
from fuzzforge_ai.config_bridge import ProjectConfigManager
# Suppress warnings
warnings.filterwarnings("ignore")
logging.getLogger("google.adk").setLevel(logging.ERROR)
logging.getLogger("google.adk.tools.base_authenticated_tool").setLevel(logging.ERROR)
# Load .env from .fuzzforge directory first, then fallback
from pathlib import Path
# Ensure Cognee logs stay inside the project workspace
project_root = Path.cwd()
default_log_dir = project_root / ".fuzzforge" / "logs"
default_log_dir.mkdir(parents=True, exist_ok=True)
log_path = default_log_dir / "cognee.log"
os.environ.setdefault("COGNEE_LOG_PATH", str(log_path))
fuzzforge_env = Path.cwd() / ".fuzzforge" / ".env"
if fuzzforge_env.exists():
load_dotenv(fuzzforge_env, override=True)
else:
load_dotenv(override=True)
# Ensure Cognee uses the project-specific storage paths when available
try:
project_config = ProjectConfigManager()
project_config.setup_cognee_environment()
except Exception:
# Project may not be initialized; fall through with default settings
pass
# Check configuration
if not os.getenv('LITELLM_MODEL'):
print("[ERROR] LITELLM_MODEL not set in .env file")
print("Please set LITELLM_MODEL to your desired model (e.g., gpt-4o-mini)")
exit(1)
from .agent import get_fuzzforge_agent
from .a2a_server import create_a2a_app as create_custom_a2a_app
def create_a2a_app():
"""Create the A2A application"""
# Get configuration
port = int(os.getenv('FUZZFORGE_PORT', 10100))
# Get the FuzzForge agent
fuzzforge = get_fuzzforge_agent()
# Print ASCII banner
print("\033[95m") # Purple color
print(" ███████╗██╗ ██╗███████╗███████╗███████╗ ██████╗ ██████╗ ██████╗ ███████╗ █████╗ ██╗")
print(" ██╔════╝██║ ██║╚══███╔╝╚══███╔╝██╔════╝██╔═══██╗██╔══██╗██╔════╝ ██╔════╝ ██╔══██╗██║")
print(" █████╗ ██║ ██║ ███╔╝ ███╔╝ █████╗ ██║ ██║██████╔╝██║ ███╗█████╗ ███████║██║")
print(" ██╔══╝ ██║ ██║ ███╔╝ ███╔╝ ██╔══╝ ██║ ██║██╔══██╗██║ ██║██╔══╝ ██╔══██║██║")
print(" ██║ ╚██████╔╝███████╗███████╗██║ ╚██████╔╝██║ ██║╚██████╔╝███████╗ ██║ ██║██║")
print(" ╚═╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═╝╚═╝")
print("\033[0m") # Reset color
# Create A2A app
print(f"🚀 Starting FuzzForge A2A Server")
print(f" Model: {fuzzforge.model}")
if fuzzforge.cognee_url:
print(f" Memory: Cognee at {fuzzforge.cognee_url}")
print(f" Port: {port}")
app = create_custom_a2a_app(fuzzforge.adk_agent, port=port, executor=fuzzforge.executor)
print(f"\n✅ FuzzForge A2A Server ready!")
print(f" Agent card: http://localhost:{port}/.well-known/agent-card.json")
print(f" A2A endpoint: http://localhost:{port}/")
print(f"\n📡 Other agents can register FuzzForge at: http://localhost:{port}")
return app
def main():
"""Start the A2A server using uvicorn."""
import uvicorn
app = create_a2a_app()
port = int(os.getenv('FUZZFORGE_PORT', 10100))
print(f"\n🎯 Starting server with uvicorn...")
uvicorn.run(app, host="127.0.0.1", port=port)
if __name__ == "__main__":
main()
+230
View File
@@ -0,0 +1,230 @@
"""Custom A2A wiring so we can access task store and queue manager."""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
from __future__ import annotations
import logging
from typing import Optional, Union
from starlette.applications import Starlette
from starlette.responses import Response, FileResponse
from starlette.routing import Route
from google.adk.a2a.executor.a2a_agent_executor import A2aAgentExecutor
from google.adk.a2a.utils.agent_card_builder import AgentCardBuilder
from google.adk.a2a.experimental import a2a_experimental
from google.adk.agents.base_agent import BaseAgent
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
from google.adk.auth.credential_service.in_memory_credential_service import InMemoryCredentialService
from google.adk.cli.utils.logs import setup_adk_logger
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService
from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers.default_request_handler import DefaultRequestHandler
from a2a.server.tasks.inmemory_task_store import InMemoryTaskStore
from a2a.server.events.in_memory_queue_manager import InMemoryQueueManager
from a2a.types import AgentCard
from .agent_executor import FuzzForgeExecutor
import json
async def serve_artifact(request):
"""Serve artifact files via HTTP for A2A agents"""
artifact_id = request.path_params["artifact_id"]
# Try to get the executor instance to access artifact cache
# We'll store a reference to it during app creation
executor = getattr(serve_artifact, '_executor', None)
if not executor:
return Response("Artifact service not available", status_code=503)
try:
# Look in the artifact cache directory
artifact_cache_dir = executor._artifact_cache_dir
artifact_dir = artifact_cache_dir / artifact_id
if not artifact_dir.exists():
return Response("Artifact not found", status_code=404)
# Find the artifact file (should be only one file in the directory)
artifact_files = list(artifact_dir.glob("*"))
if not artifact_files:
return Response("Artifact file not found", status_code=404)
artifact_file = artifact_files[0] # Take the first (and should be only) file
# Determine mime type from file extension or default to octet-stream
import mimetypes
mime_type, _ = mimetypes.guess_type(str(artifact_file))
if not mime_type:
mime_type = 'application/octet-stream'
return FileResponse(
path=str(artifact_file),
media_type=mime_type,
filename=artifact_file.name
)
except Exception as e:
return Response(f"Error serving artifact: {str(e)}", status_code=500)
async def knowledge_query(request):
"""Expose knowledge graph search over HTTP for external agents."""
executor = getattr(knowledge_query, '_executor', None)
if not executor:
return Response("Knowledge service not available", status_code=503)
try:
payload = await request.json()
except Exception:
return Response("Invalid JSON body", status_code=400)
query = payload.get("query")
if not query:
return Response("'query' is required", status_code=400)
search_type = payload.get("search_type", "INSIGHTS")
dataset = payload.get("dataset")
result = await executor.query_project_knowledge_api(
query=query,
search_type=search_type,
dataset=dataset,
)
status = 200 if not isinstance(result, dict) or "error" not in result else 400
return Response(
json.dumps(result, default=str),
status_code=status,
media_type="application/json",
)
async def create_file_artifact(request):
"""Create an artifact from a project file via HTTP."""
executor = getattr(create_file_artifact, '_executor', None)
if not executor:
return Response("File service not available", status_code=503)
try:
payload = await request.json()
except Exception:
return Response("Invalid JSON body", status_code=400)
path = payload.get("path")
if not path:
return Response("'path' is required", status_code=400)
result = await executor.create_project_file_artifact_api(path)
status = 200 if not isinstance(result, dict) or "error" not in result else 400
return Response(
json.dumps(result, default=str),
status_code=status,
media_type="application/json",
)
def _load_agent_card(agent_card: Optional[Union[AgentCard, str]]) -> Optional[AgentCard]:
if agent_card is None:
return None
if isinstance(agent_card, AgentCard):
return agent_card
import json
from pathlib import Path
path = Path(agent_card)
with path.open('r', encoding='utf-8') as handle:
data = json.load(handle)
return AgentCard(**data)
@a2a_experimental
def create_a2a_app(
agent: BaseAgent,
*,
host: str = "localhost",
port: int = 8000,
protocol: str = "http",
agent_card: Optional[Union[AgentCard, str]] = None,
executor=None, # Accept executor reference
) -> Starlette:
"""Variant of google.adk.a2a.utils.to_a2a that exposes task-store handles."""
setup_adk_logger(logging.INFO)
async def create_runner() -> Runner:
return Runner(
agent=agent,
app_name=agent.name or "fuzzforge",
artifact_service=InMemoryArtifactService(),
session_service=InMemorySessionService(),
memory_service=InMemoryMemoryService(),
credential_service=InMemoryCredentialService(),
)
task_store = InMemoryTaskStore()
queue_manager = InMemoryQueueManager()
agent_executor = A2aAgentExecutor(runner=create_runner)
request_handler = DefaultRequestHandler(
agent_executor=agent_executor,
task_store=task_store,
queue_manager=queue_manager,
)
rpc_url = f"{protocol}://{host}:{port}/"
provided_card = _load_agent_card(agent_card)
card_builder = AgentCardBuilder(agent=agent, rpc_url=rpc_url)
app = Starlette()
async def setup() -> None:
if provided_card is not None:
final_card = provided_card
else:
final_card = await card_builder.build()
a2a_app = A2AStarletteApplication(
agent_card=final_card,
http_handler=request_handler,
)
a2a_app.add_routes_to_app(app)
# Add artifact serving route
app.router.add_route("/artifacts/{artifact_id}", serve_artifact, methods=["GET"])
app.router.add_route("/graph/query", knowledge_query, methods=["POST"])
app.router.add_route("/project/files", create_file_artifact, methods=["POST"])
app.add_event_handler("startup", setup)
# Expose handles so the executor can emit task updates later
FuzzForgeExecutor.task_store = task_store
FuzzForgeExecutor.queue_manager = queue_manager
# Store reference to executor for artifact serving
serve_artifact._executor = executor
knowledge_query._executor = executor
create_file_artifact._executor = executor
return app
__all__ = ["create_a2a_app"]
+133
View File
@@ -0,0 +1,133 @@
"""
FuzzForge Agent Definition
The core agent that combines all components
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import os
from pathlib import Path
from typing import Dict, Any, List
from google.adk import Agent
from google.adk.models.lite_llm import LiteLlm
from .agent_card import get_fuzzforge_agent_card
from .agent_executor import FuzzForgeExecutor
from .memory_service import FuzzForgeMemoryService, HybridMemoryManager
# Load environment variables from the AI module's .env file
try:
from dotenv import load_dotenv
_ai_dir = Path(__file__).parent
_env_file = _ai_dir / ".env"
if _env_file.exists():
load_dotenv(_env_file, override=False) # Don't override existing env vars
except ImportError:
# dotenv not available, skip loading
pass
class FuzzForgeAgent:
"""The main FuzzForge agent that combines card, executor, and ADK agent"""
def __init__(
self,
model: str = None,
cognee_url: str = None,
port: int = 10100,
):
"""Initialize FuzzForge agent with configuration"""
self.model = model or os.getenv('LITELLM_MODEL', 'gpt-4o-mini')
self.cognee_url = cognee_url or os.getenv('COGNEE_MCP_URL')
self.port = port
# Initialize ADK Memory Service for conversational memory
memory_type = os.getenv('MEMORY_SERVICE', 'inmemory')
self.memory_service = FuzzForgeMemoryService(memory_type=memory_type)
# Create the executor (the brain) with memory and session services
self.executor = FuzzForgeExecutor(
model=self.model,
cognee_url=self.cognee_url,
debug=os.getenv('FUZZFORGE_DEBUG', '0') == '1',
memory_service=self.memory_service,
session_persistence=os.getenv('SESSION_PERSISTENCE', 'inmemory'),
fuzzforge_mcp_url=os.getenv('FUZZFORGE_MCP_URL'),
)
# Create Hybrid Memory Manager (ADK + Cognee direct integration)
# MCP tools removed - using direct Cognee integration only
self.memory_manager = HybridMemoryManager(
memory_service=self.memory_service,
cognee_tools=None # No MCP tools, direct integration used instead
)
# Get the agent card (the identity)
self.agent_card = get_fuzzforge_agent_card(f"http://localhost:{self.port}")
# Create the ADK agent (for A2A server mode)
self.adk_agent = self._create_adk_agent()
def _create_adk_agent(self) -> Agent:
"""Create the ADK agent for A2A server mode"""
# Build instruction
instruction = f"""You are {self.agent_card.name}, {self.agent_card.description}
Your capabilities include:
"""
for skill in self.agent_card.skills:
instruction += f"\n- {skill.name}: {skill.description}"
instruction += """
When responding to requests:
1. Use your registered agents when appropriate
2. Use Cognee memory tools when available
3. Provide helpful, concise responses
4. Maintain context across conversations
"""
# Create ADK agent
return Agent(
model=LiteLlm(model=self.model),
name=self.agent_card.name,
description=self.agent_card.description,
instruction=instruction,
tools=self.executor.agent.tools if hasattr(self.executor.agent, 'tools') else []
)
async def process_message(self, message: str, context_id: str = None) -> str:
"""Process a message using the executor"""
result = await self.executor.execute(message, context_id or "default")
return result.get("response", "No response generated")
async def register_agent(self, url: str) -> Dict[str, Any]:
"""Register a new agent"""
return await self.executor.register_agent(url)
def list_agents(self) -> List[Dict[str, Any]]:
"""List registered agents"""
return self.executor.list_agents()
async def cleanup(self):
"""Clean up resources"""
await self.executor.cleanup()
# Create a singleton instance for import
_instance = None
def get_fuzzforge_agent() -> FuzzForgeAgent:
"""Get the singleton FuzzForge agent instance"""
global _instance
if _instance is None:
_instance = FuzzForgeAgent()
return _instance
+183
View File
@@ -0,0 +1,183 @@
"""
FuzzForge Agent Card and Skills Definition
Defines what FuzzForge can do and how others can discover it
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
from dataclasses import dataclass
from typing import List, Optional, Dict, Any
@dataclass
class AgentSkill:
"""Represents a specific capability of the agent"""
id: str
name: str
description: str
tags: List[str]
examples: List[str]
input_modes: List[str] = None
output_modes: List[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization"""
return {
"id": self.id,
"name": self.name,
"description": self.description,
"tags": self.tags,
"examples": self.examples,
"inputModes": self.input_modes or ["text/plain"],
"outputModes": self.output_modes or ["text/plain"]
}
@dataclass
class AgentCapabilities:
"""Defines agent capabilities for A2A protocol"""
streaming: bool = False
push_notifications: bool = False
multi_turn: bool = True
context_retention: bool = True
def to_dict(self) -> Dict[str, Any]:
return {
"streaming": self.streaming,
"pushNotifications": self.push_notifications,
"multiTurn": self.multi_turn,
"contextRetention": self.context_retention
}
@dataclass
class AgentCard:
"""The agent's business card - tells others what this agent can do"""
name: str
description: str
version: str
url: str
skills: List[AgentSkill]
capabilities: AgentCapabilities
default_input_modes: List[str] = None
default_output_modes: List[str] = None
preferred_transport: str = "JSONRPC"
protocol_version: str = "0.3.0"
def to_dict(self) -> Dict[str, Any]:
"""Convert to A2A-compliant agent card JSON"""
return {
"name": self.name,
"description": self.description,
"version": self.version,
"url": self.url,
"protocolVersion": self.protocol_version,
"preferredTransport": self.preferred_transport,
"defaultInputModes": self.default_input_modes or ["text/plain"],
"defaultOutputModes": self.default_output_modes or ["text/plain"],
"capabilities": self.capabilities.to_dict(),
"skills": [skill.to_dict() for skill in self.skills]
}
# Define FuzzForge's skills
orchestration_skill = AgentSkill(
id="orchestration",
name="Agent Orchestration",
description="Route requests to appropriate registered agents based on their capabilities",
tags=["orchestration", "routing", "coordination"],
examples=[
"Route this to the calculator",
"Send this to the appropriate agent",
"Which agent should handle this?"
]
)
memory_skill = AgentSkill(
id="memory",
name="Memory Management",
description="Store and retrieve information using Cognee knowledge graph",
tags=["memory", "knowledge", "storage", "cognee"],
examples=[
"Remember that my favorite color is blue",
"What do you remember about me?",
"Search your memory for project details"
]
)
conversation_skill = AgentSkill(
id="conversation",
name="General Conversation",
description="Engage in general conversation and answer questions using LLM",
tags=["chat", "conversation", "qa", "llm"],
examples=[
"What is the meaning of life?",
"Explain quantum computing",
"Help me understand this concept"
]
)
workflow_automation_skill = AgentSkill(
id="workflow_automation",
name="Workflow Automation",
description="Operate project workflows via MCP, monitor runs, and share results",
tags=["workflow", "automation", "mcp", "orchestration"],
examples=[
"Submit the security assessment workflow",
"Kick off the infrastructure scan and monitor it",
"Summarise findings for run abc123"
]
)
agent_management_skill = AgentSkill(
id="agent_management",
name="Agent Registry Management",
description="Register, list, and manage connections to other A2A agents",
tags=["registry", "management", "discovery"],
examples=[
"Register agent at http://localhost:10201",
"List all registered agents",
"Show agent capabilities"
]
)
# Define FuzzForge's capabilities
fuzzforge_capabilities = AgentCapabilities(
streaming=False,
push_notifications=True,
multi_turn=True, # We support multi-turn conversations
context_retention=True # We maintain context across turns
)
# Create the public agent card
def get_fuzzforge_agent_card(url: str = "http://localhost:10100") -> AgentCard:
"""Get FuzzForge's agent card with current configuration"""
return AgentCard(
name="ProjectOrchestrator",
description=(
"An A2A-capable project agent that can launch and monitor FuzzForge workflows, "
"consult the project knowledge graph, and coordinate with speciality agents."
),
version="project-agent",
url=url,
skills=[
orchestration_skill,
memory_skill,
conversation_skill,
workflow_automation_skill,
agent_management_skill
],
capabilities=fuzzforge_capabilities,
default_input_modes=["text/plain", "application/json"],
default_output_modes=["text/plain", "application/json"],
preferred_transport="JSONRPC",
protocol_version="0.3.0"
)
File diff suppressed because it is too large Load Diff
+977
View File
@@ -0,0 +1,977 @@
#!/usr/bin/env python3
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
"""
FuzzForge CLI - Clean modular version
Uses the separated agent components
"""
import asyncio
import shlex
import os
import sys
import signal
import warnings
import logging
import random
from datetime import datetime
from contextlib import contextmanager
from pathlib import Path
from typing import Any
from dotenv import load_dotenv
# Ensure Cognee writes logs inside the project workspace
project_root = Path.cwd()
default_log_dir = project_root / ".fuzzforge" / "logs"
default_log_dir.mkdir(parents=True, exist_ok=True)
log_path = default_log_dir / "cognee.log"
os.environ.setdefault("COGNEE_LOG_PATH", str(log_path))
# Suppress warnings
warnings.filterwarnings("ignore")
logging.basicConfig(level=logging.ERROR)
# Load .env file with explicit path handling
# 1. First check current working directory for .fuzzforge/.env
fuzzforge_env = Path.cwd() / ".fuzzforge" / ".env"
if fuzzforge_env.exists():
load_dotenv(fuzzforge_env, override=True)
else:
# 2. Then check parent directories for .fuzzforge projects
current_path = Path.cwd()
for parent in [current_path] + list(current_path.parents):
fuzzforge_dir = parent / ".fuzzforge"
if fuzzforge_dir.exists():
project_env = fuzzforge_dir / ".env"
if project_env.exists():
load_dotenv(project_env, override=True)
break
else:
# 3. Fallback to generic load_dotenv
load_dotenv(override=True)
# Enhanced readline configuration for Rich Console input compatibility
try:
import readline
# Enable Rich-compatible input features
readline.parse_and_bind("tab: complete")
readline.parse_and_bind("set editing-mode emacs")
readline.parse_and_bind("set show-all-if-ambiguous on")
readline.parse_and_bind("set completion-ignore-case on")
readline.parse_and_bind("set colored-completion-prefix on")
readline.parse_and_bind("set enable-bracketed-paste on") # Better paste support
# Navigation bindings for better editing
readline.parse_and_bind("Control-a: beginning-of-line")
readline.parse_and_bind("Control-e: end-of-line")
readline.parse_and_bind("Control-u: unix-line-discard")
readline.parse_and_bind("Control-k: kill-line")
readline.parse_and_bind("Control-w: unix-word-rubout")
readline.parse_and_bind("Meta-Backspace: backward-kill-word")
# History and completion
readline.set_history_length(2000)
readline.set_startup_hook(None)
# Enable multiline editing hints
readline.parse_and_bind("set horizontal-scroll-mode off")
readline.parse_and_bind("set mark-symlinked-directories on")
READLINE_AVAILABLE = True
except ImportError:
READLINE_AVAILABLE = False
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.prompt import Prompt
from rich import box
from google.adk.events.event import Event
from google.adk.events.event_actions import EventActions
from google.genai import types as gen_types
from .agent import FuzzForgeAgent
from .agent_card import get_fuzzforge_agent_card
from .config_manager import ConfigManager
from .config_bridge import ProjectConfigManager
from .remote_agent import RemoteAgentConnection
console = Console()
# Global shutdown flag
shutdown_requested = False
# Dynamic status messages for better UX
THINKING_MESSAGES = [
"Thinking", "Processing", "Computing", "Analyzing", "Working",
"Pondering", "Deliberating", "Calculating", "Reasoning", "Evaluating"
]
WORKING_MESSAGES = [
"Working", "Processing", "Handling", "Executing", "Running",
"Operating", "Performing", "Conducting", "Managing", "Coordinating"
]
SEARCH_MESSAGES = [
"Searching", "Scanning", "Exploring", "Investigating", "Hunting",
"Seeking", "Probing", "Examining", "Inspecting", "Browsing"
]
# Cool prompt symbols
PROMPT_STYLES = [
"", "", "", "", "»", "", "", "", "", ""
]
def get_dynamic_status(action_type="thinking"):
"""Get a random status message based on action type"""
if action_type == "thinking":
return f"{random.choice(THINKING_MESSAGES)}..."
elif action_type == "working":
return f"{random.choice(WORKING_MESSAGES)}..."
elif action_type == "searching":
return f"{random.choice(SEARCH_MESSAGES)}..."
else:
return f"{random.choice(THINKING_MESSAGES)}..."
def get_prompt_symbol():
"""Get prompt symbol indicating where to write"""
return ">>"
def signal_handler(signum, frame):
"""Handle Ctrl+C gracefully"""
global shutdown_requested
shutdown_requested = True
console.print("\n\n[yellow]Shutting down gracefully...[/yellow]")
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
@contextmanager
def safe_status(message: str):
"""Safe status context manager"""
status = console.status(message, spinner="dots")
try:
status.start()
yield
finally:
status.stop()
class FuzzForgeCLI:
"""Command-line interface for FuzzForge"""
def __init__(self):
"""Initialize the CLI"""
# Ensure .env is loaded from .fuzzforge directory
fuzzforge_env = Path.cwd() / ".fuzzforge" / ".env"
if fuzzforge_env.exists():
load_dotenv(fuzzforge_env, override=True)
# Load configuration for agent registry
self.config_manager = ConfigManager()
# Check environment configuration
if not os.getenv('LITELLM_MODEL'):
console.print("[red]ERROR: LITELLM_MODEL not set in .env file[/red]")
console.print("Please set LITELLM_MODEL to your desired model")
sys.exit(1)
# Create the agent (uses env vars directly)
self.agent = FuzzForgeAgent()
# Create a consistent context ID for this CLI session
self.context_id = f"cli_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
# Track registered agents for config persistence
self.agents_modified = False
# Command handlers
self.commands = {
"/help": self.cmd_help,
"/register": self.cmd_register,
"/unregister": self.cmd_unregister,
"/list": self.cmd_list,
"/memory": self.cmd_memory,
"/recall": self.cmd_recall,
"/artifacts": self.cmd_artifacts,
"/tasks": self.cmd_tasks,
"/skills": self.cmd_skills,
"/sessions": self.cmd_sessions,
"/clear": self.cmd_clear,
"/sendfile": self.cmd_sendfile,
"/quit": self.cmd_quit,
"/exit": self.cmd_quit,
}
self.background_tasks: set[asyncio.Task] = set()
def print_banner(self):
"""Print welcome banner"""
card = self.agent.agent_card
# Print ASCII banner
console.print("[medium_purple3] ███████╗██╗ ██╗███████╗███████╗███████╗ ██████╗ ██████╗ ██████╗ ███████╗ █████╗ ██╗[/medium_purple3]")
console.print("[medium_purple3] ██╔════╝██║ ██║╚══███╔╝╚══███╔╝██╔════╝██╔═══██╗██╔══██╗██╔════╝ ██╔════╝ ██╔══██╗██║[/medium_purple3]")
console.print("[medium_purple3] █████╗ ██║ ██║ ███╔╝ ███╔╝ █████╗ ██║ ██║██████╔╝██║ ███╗█████╗ ███████║██║[/medium_purple3]")
console.print("[medium_purple3] ██╔══╝ ██║ ██║ ███╔╝ ███╔╝ ██╔══╝ ██║ ██║██╔══██╗██║ ██║██╔══╝ ██╔══██║██║[/medium_purple3]")
console.print("[medium_purple3] ██║ ╚██████╔╝███████╗███████╗██║ ╚██████╔╝██║ ██║╚██████╔╝███████╗ ██║ ██║██║[/medium_purple3]")
console.print("[medium_purple3] ╚═╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═╝╚═╝[/medium_purple3]")
console.print(f"\n[dim]{card.description}[/dim]\n")
provider = (
os.getenv("LLM_PROVIDER")
or os.getenv("LLM_COGNEE_PROVIDER")
or os.getenv("COGNEE_LLM_PROVIDER")
or "unknown"
)
console.print(
"LLM Provider: [medium_purple1]{provider}[/medium_purple1]".format(
provider=provider
)
)
console.print(
"LLM Model: [medium_purple1]{model}[/medium_purple1]".format(
model=self.agent.model
)
)
if self.agent.executor.agentops_trace:
console.print(f"Tracking: [medium_purple1]AgentOps active[/medium_purple1]")
# Show skills
console.print("\nSkills:")
for skill in card.skills:
console.print(
f" • [deep_sky_blue1]{skill.name}[/deep_sky_blue1] {skill.description}"
)
console.print("\nType /help for commands or just chat\n")
async def cmd_help(self, args: str = "") -> None:
"""Show help"""
help_text = """
[bold]Commands:[/bold]
/register <url> - Register an A2A agent (saves to config)
/unregister <name> - Remove agent from registry and config
/list - List registered agents
[bold]Memory Systems:[/bold]
/recall <query> - Search past conversations (ADK Memory)
/memory - Show knowledge graph (Cognee)
/memory save - Save to knowledge graph
/memory search - Search knowledge graph
[bold]Other:[/bold]
/artifacts - List created artifacts
/artifacts <id> - Show artifact content
/tasks [id] - Show task list or details
/skills - Show FuzzForge skills
/sessions - List active sessions
/sendfile <agent> <path> [message] - Attach file as artifact and route to agent
/clear - Clear screen
/help - Show this help
/quit - Exit
[bold]Sample prompts:[/bold]
run fuzzforge workflow security_assessment on /absolute/path --volume-mode ro
list fuzzforge runs limit=5
get fuzzforge summary <run_id>
query project knowledge about "unsafe Rust" using GRAPH_COMPLETION
export project file src/lib.rs as artifact
/memory search "recent findings"
[bold]Input Editing:[/bold]
Arrow keys - Move cursor
Ctrl+A/E - Start/end of line
Up/Down - Command history
"""
console.print(help_text)
async def cmd_register(self, args: str) -> None:
"""Register an agent"""
if not args:
console.print("Usage: /register <url>")
return
with safe_status(f"{get_dynamic_status('working')} Registering {args}"):
result = await self.agent.register_agent(args.strip())
if result["success"]:
console.print(f"✅ Registered: [bold]{result['name']}[/bold]")
console.print(f" Capabilities: {result['capabilities']} skills")
# Get description from the agent's card
agents = self.agent.list_agents()
description = ""
for agent in agents:
if agent['name'] == result['name']:
description = agent.get('description', '')
break
# Add to config for persistence
self.config_manager.add_registered_agent(
name=result['name'],
url=args.strip(),
description=description
)
console.print(f" [dim]Saved to config for auto-registration[/dim]")
else:
console.print(f"[red]Failed: {result['error']}[/red]")
async def cmd_unregister(self, args: str) -> None:
"""Unregister an agent and remove from config"""
if not args:
console.print("Usage: /unregister <name or url>")
return
# Try to find the agent
agents = self.agent.list_agents()
agent_to_remove = None
for agent in agents:
if agent['name'].lower() == args.lower() or agent['url'] == args:
agent_to_remove = agent
break
if not agent_to_remove:
console.print(f"[yellow]Agent '{args}' not found[/yellow]")
return
# Remove from config
if self.config_manager.remove_registered_agent(name=agent_to_remove['name'], url=agent_to_remove['url']):
console.print(f"✅ Unregistered: [bold]{agent_to_remove['name']}[/bold]")
console.print(f" [dim]Removed from config (won't auto-register next time)[/dim]")
else:
console.print(f"[yellow]Agent unregistered from session but not found in config[/yellow]")
async def cmd_list(self, args: str = "") -> None:
"""List registered agents"""
agents = self.agent.list_agents()
if not agents:
console.print("No agents registered. Use /register <url>")
return
table = Table(title="Registered Agents", box=box.ROUNDED)
table.add_column("Name", style="medium_purple3")
table.add_column("URL", style="deep_sky_blue3")
table.add_column("Skills", style="plum3")
table.add_column("Description", style="dim")
for agent in agents:
desc = agent['description']
if len(desc) > 40:
desc = desc[:37] + "..."
table.add_row(
agent['name'],
agent['url'],
str(agent['skills']),
desc
)
console.print(table)
async def cmd_recall(self, args: str = "") -> None:
"""Search conversational memory (past conversations)"""
if not args:
console.print("Usage: /recall <query>")
return
await self._sync_conversational_memory()
# First try MemoryService (for ingested memories)
with safe_status(get_dynamic_status('searching')):
results = await self.agent.memory_manager.search_conversational_memory(args)
if results and results.memories:
console.print(f"[bold]Found {len(results.memories)} memories:[/bold]\n")
for i, memory in enumerate(results.memories, 1):
# MemoryEntry has 'text' field, not 'content'
text = getattr(memory, 'text', str(memory))
if len(text) > 200:
text = text[:200] + "..."
console.print(f"{i}. {text}")
else:
# If MemoryService is empty, search SQLite directly
console.print("[yellow]No memories in MemoryService, searching SQLite sessions...[/yellow]")
# Check if using DatabaseSessionService
if hasattr(self.agent.executor, 'session_service'):
service_type = type(self.agent.executor.session_service).__name__
if service_type == 'DatabaseSessionService':
# Search SQLite database directly
import sqlite3
import os
db_path = os.getenv('SESSION_DB_PATH', './fuzzforge_sessions.db')
if os.path.exists(db_path):
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Search in events table
query = f"%{args}%"
cursor.execute(
"SELECT content FROM events WHERE content LIKE ? LIMIT 10",
(query,)
)
rows = cursor.fetchall()
conn.close()
if rows:
console.print(f"[green]Found {len(rows)} matches in SQLite sessions:[/green]\n")
for i, (content,) in enumerate(rows, 1):
# Parse JSON content
import json
try:
data = json.loads(content)
if 'parts' in data and data['parts']:
text = data['parts'][0].get('text', '')[:150]
role = data.get('role', 'unknown')
console.print(f"{i}. [{role}]: {text}...")
except:
console.print(f"{i}. {content[:150]}...")
else:
console.print("[yellow]No matches found in SQLite either[/yellow]")
else:
console.print("[yellow]SQLite database not found[/yellow]")
else:
console.print(f"[dim]Using {service_type} (not searchable)[/dim]")
else:
console.print("[yellow]No session history available[/yellow]")
async def cmd_memory(self, args: str = "") -> None:
"""Inspect conversational memory and knowledge graph state."""
raw_args = (args or "").strip()
lower_args = raw_args.lower()
if not raw_args or lower_args in {"status", "info"}:
await self._show_memory_status()
return
if lower_args == "datasets":
await self._show_dataset_summary()
return
if lower_args.startswith("search ") or lower_args.startswith("recall "):
query = raw_args.split(" ", 1)[1].strip() if " " in raw_args else ""
if not query:
console.print("Usage: /memory search <query>")
return
await self.cmd_recall(query)
return
console.print("Usage: /memory [status|datasets|search <query>]")
console.print("[dim]/memory search <query> is an alias for /recall <query>[/dim]")
async def _sync_conversational_memory(self) -> None:
"""Ensure the ADK memory service ingests any completed sessions."""
memory_service = getattr(self.agent.memory_manager, "memory_service", None)
executor_sessions = getattr(self.agent.executor, "sessions", {})
metadata_map = getattr(self.agent.executor, "session_metadata", {})
if not memory_service or not executor_sessions:
return
for context_id, session in list(executor_sessions.items()):
meta = metadata_map.get(context_id, {})
if meta.get('memory_synced'):
continue
add_session = getattr(memory_service, "add_session_to_memory", None)
if not callable(add_session):
return
try:
await add_session(session)
meta['memory_synced'] = True
metadata_map[context_id] = meta
except Exception as exc: # pragma: no cover - defensive logging
if os.getenv('FUZZFORGE_DEBUG', '0') == '1':
console.print(f"[yellow]Memory sync failed:[/yellow] {exc}")
async def _show_memory_status(self) -> None:
"""Render conversational memory, session store, and knowledge graph status."""
await self._sync_conversational_memory()
status = self.agent.memory_manager.get_status()
conversational = status.get("conversational_memory", {})
conv_type = conversational.get("type", "unknown")
conv_active = "yes" if conversational.get("active") else "no"
conv_details = conversational.get("details", "")
session_service = getattr(self.agent.executor, "session_service", None)
session_service_name = type(session_service).__name__ if session_service else "Unavailable"
session_lines = [
f"[bold]Service:[/bold] {session_service_name}"
]
session_count = None
event_count = None
db_path_display = None
if session_service_name == "DatabaseSessionService":
import sqlite3
db_path = os.getenv('SESSION_DB_PATH', './fuzzforge_sessions.db')
session_path = Path(db_path).expanduser().resolve()
db_path_display = str(session_path)
if session_path.exists():
try:
with sqlite3.connect(session_path) as conn:
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM sessions")
session_count = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM events")
event_count = cursor.fetchone()[0]
except Exception as exc:
session_lines.append(f"[yellow]Warning:[/yellow] Unable to read session database ({exc})")
else:
session_lines.append("[yellow]SQLite session database not found yet[/yellow]")
elif session_service_name == "InMemorySessionService":
session_lines.append("[dim]Session data persists for the current process only[/dim]")
if db_path_display:
session_lines.append(f"[bold]Database:[/bold] {db_path_display}")
if session_count is not None:
session_lines.append(f"[bold]Sessions Recorded:[/bold] {session_count}")
if event_count is not None:
session_lines.append(f"[bold]Events Logged:[/bold] {event_count}")
conv_lines = [
f"[bold]Type:[/bold] {conv_type}",
f"[bold]Active:[/bold] {conv_active}"
]
if conv_details:
conv_lines.append(f"[bold]Details:[/bold] {conv_details}")
console.print(Panel("\n".join(conv_lines), title="Conversation Memory", border_style="medium_purple3"))
console.print(Panel("\n".join(session_lines), title="Session Store", border_style="deep_sky_blue3"))
# Knowledge graph section
knowledge = status.get("knowledge_graph", {})
kg_active = knowledge.get("active", False)
kg_lines = [
f"[bold]Active:[/bold] {'yes' if kg_active else 'no'}",
f"[bold]Purpose:[/bold] {knowledge.get('purpose', 'N/A')}"
]
cognee_data = None
cognee_error = None
try:
project_config = ProjectConfigManager()
cognee_data = project_config.get_cognee_config()
except Exception as exc: # pragma: no cover - defensive
cognee_error = str(exc)
if cognee_data:
data_dir = cognee_data.get('data_directory')
system_dir = cognee_data.get('system_directory')
if data_dir:
kg_lines.append(f"[bold]Data dir:[/bold] {data_dir}")
if system_dir:
kg_lines.append(f"[bold]System dir:[/bold] {system_dir}")
elif cognee_error:
kg_lines.append(f"[yellow]Config unavailable:[/yellow] {cognee_error}")
dataset_summary = None
if kg_active:
try:
integration = await self.agent.executor._get_knowledge_integration()
if integration:
dataset_summary = await integration.list_datasets()
except Exception as exc: # pragma: no cover - defensive
kg_lines.append(f"[yellow]Dataset listing failed:[/yellow] {exc}")
if dataset_summary:
if dataset_summary.get("error"):
kg_lines.append(f"[yellow]Dataset listing failed:[/yellow] {dataset_summary['error']}")
else:
datasets = dataset_summary.get("datasets", [])
total = dataset_summary.get("total_datasets")
if total is not None:
kg_lines.append(f"[bold]Datasets:[/bold] {total}")
if datasets:
preview = ", ".join(sorted(datasets)[:5])
if len(datasets) > 5:
preview += ", …"
kg_lines.append(f"[bold]Samples:[/bold] {preview}")
else:
kg_lines.append("[dim]Run `fuzzforge ingest` to populate the knowledge graph[/dim]")
console.print(Panel("\n".join(kg_lines), title="Knowledge Graph", border_style="spring_green4"))
console.print("\n[dim]Subcommands: /memory datasets | /memory search <query>[/dim]")
async def _show_dataset_summary(self) -> None:
"""List datasets available in the Cognee knowledge graph."""
try:
integration = await self.agent.executor._get_knowledge_integration()
except Exception as exc:
console.print(f"[yellow]Knowledge graph unavailable:[/yellow] {exc}")
return
if not integration:
console.print("[yellow]Knowledge graph is not initialised yet.[/yellow]")
console.print("[dim]Run `fuzzforge ingest --path . --recursive` to create the project dataset.[/dim]")
return
with safe_status(get_dynamic_status('searching')):
dataset_info = await integration.list_datasets()
if dataset_info.get("error"):
console.print(f"[red]{dataset_info['error']}[/red]")
return
datasets = dataset_info.get("datasets", [])
if not datasets:
console.print("[yellow]No datasets found.[/yellow]")
console.print("[dim]Run `fuzzforge ingest` to populate the knowledge graph.[/dim]")
return
table = Table(title="Cognee Datasets", box=box.ROUNDED)
table.add_column("Dataset", style="medium_purple3")
table.add_column("Notes", style="dim")
for name in sorted(datasets):
note = ""
if name.endswith("_codebase"):
note = "primary project dataset"
table.add_row(name, note)
console.print(table)
console.print(
"[dim]Use knowledge graph prompts (e.g. `search project knowledge for \"topic\" using INSIGHTS`) to query these datasets.[/dim]"
)
async def cmd_artifacts(self, args: str = "") -> None:
"""List or show artifacts"""
if args:
# Show specific artifact
artifacts = await self.agent.executor.get_artifacts(self.context_id)
for artifact in artifacts:
if artifact['id'] == args or args in artifact['id']:
console.print(Panel(
f"[bold]{artifact['title']}[/bold]\n"
f"Type: {artifact['type']} | Created: {artifact['created_at'][:19]}\n\n"
f"[code]{artifact['content']}[/code]",
title=f"Artifact: {artifact['id']}",
border_style="medium_purple3"
))
return
console.print(f"[yellow]Artifact {args} not found[/yellow]")
return
# List all artifacts
artifacts = await self.agent.executor.get_artifacts(self.context_id)
if not artifacts:
console.print("No artifacts created yet")
console.print("[dim]Artifacts are created when generating code, configs, or documents[/dim]")
return
table = Table(title="Artifacts", box=box.ROUNDED)
table.add_column("ID", style="medium_purple3")
table.add_column("Type", style="deep_sky_blue3")
table.add_column("Title", style="plum3")
table.add_column("Size", style="dim")
table.add_column("Created", style="dim")
for artifact in artifacts:
size = f"{len(artifact['content'])} chars"
created = artifact['created_at'][:19] # Just date and time
table.add_row(
artifact['id'],
artifact['type'],
artifact['title'][:40] + "..." if len(artifact['title']) > 40 else artifact['title'],
size,
created
)
console.print(table)
console.print(f"\n[dim]Use /artifacts <id> to view artifact content[/dim]")
async def cmd_tasks(self, args: str = "") -> None:
"""List tasks or show details for a specific task."""
store = getattr(self.agent.executor, "task_store", None)
if not store or not hasattr(store, "tasks"):
console.print("Task store not available")
return
task_id = args.strip()
async with store.lock:
tasks = dict(store.tasks)
if not tasks:
console.print("No tasks recorded yet")
return
if task_id:
task = tasks.get(task_id)
if not task:
console.print(f"Task '{task_id}' not found")
return
state_str = task.status.state.value if hasattr(task.status.state, "value") else str(task.status.state)
console.print(f"\n[bold]Task {task.id}[/bold]")
console.print(f"Context: {task.context_id}")
console.print(f"State: {state_str}")
console.print(f"Timestamp: {task.status.timestamp}")
if task.metadata:
console.print("Metadata:")
for key, value in task.metadata.items():
console.print(f"{key}: {value}")
if task.history:
console.print("History:")
for entry in task.history[-5:]:
text = getattr(entry, "text", None)
if not text and hasattr(entry, "parts"):
text = " ".join(
getattr(part, "text", "") for part in getattr(entry, "parts", [])
)
console.print(f" - {text}")
return
table = Table(title="FuzzForge Tasks", box=box.ROUNDED)
table.add_column("ID", style="medium_purple3")
table.add_column("State", style="white")
table.add_column("Workflow", style="deep_sky_blue3")
table.add_column("Updated", style="green")
for task in tasks.values():
state_value = task.status.state.value if hasattr(task.status.state, "value") else str(task.status.state)
workflow = ""
if task.metadata:
workflow = task.metadata.get("workflow") or task.metadata.get("workflow_name") or ""
timestamp = task.status.timestamp if task.status else ""
table.add_row(task.id, state_value, workflow, timestamp)
console.print(table)
console.print("\n[dim]Use /tasks <id> to view task details[/dim]")
async def cmd_sessions(self, args: str = "") -> None:
"""List active sessions"""
sessions = self.agent.executor.sessions
if not sessions:
console.print("No active sessions")
return
table = Table(title="Active Sessions", box=box.ROUNDED)
table.add_column("Context ID", style="medium_purple3")
table.add_column("Session ID", style="deep_sky_blue3")
table.add_column("User ID", style="plum3")
table.add_column("State", style="dim")
for context_id, session in sessions.items():
# Get session info
session_id = getattr(session, 'id', 'N/A')
user_id = getattr(session, 'user_id', 'N/A')
state = getattr(session, 'state', {})
# Format state info
agents_count = len(state.get('registered_agents', []))
state_info = f"{agents_count} agents registered"
table.add_row(
context_id[:20] + "..." if len(context_id) > 20 else context_id,
session_id[:20] + "..." if len(str(session_id)) > 20 else str(session_id),
user_id,
state_info
)
console.print(table)
console.print(f"\n[dim]Current session: {self.context_id}[/dim]")
async def cmd_skills(self, args: str = "") -> None:
"""Show FuzzForge skills"""
card = self.agent.agent_card
table = Table(title=f"{card.name} Skills", box=box.ROUNDED)
table.add_column("Skill", style="medium_purple3")
table.add_column("Description", style="white")
table.add_column("Tags", style="deep_sky_blue3")
for skill in card.skills:
table.add_row(
skill.name,
skill.description,
", ".join(skill.tags[:3])
)
console.print(table)
async def cmd_clear(self, args: str = "") -> None:
"""Clear screen"""
console.clear()
self.print_banner()
async def cmd_sendfile(self, args: str) -> None:
"""Encode a local file as an artifact and route it to a registered agent."""
tokens = shlex.split(args)
if len(tokens) < 2:
console.print("Usage: /sendfile <agent_name> <path> [message]")
return
agent_name = tokens[0]
file_arg = tokens[1]
note = " ".join(tokens[2:]).strip()
file_path = Path(file_arg).expanduser()
if not file_path.exists():
console.print(f"[red]File not found:[/red] {file_path}")
return
session = self.agent.executor.sessions.get(self.context_id)
if not session:
console.print("[red]No active session available. Try sending a prompt first.[/red]")
return
console.print(f"[dim]Delegating {file_path.name} to {agent_name}...[/dim]")
async def _delegate() -> None:
try:
response = await self.agent.executor.delegate_file_to_agent(
agent_name,
str(file_path),
note,
session=session,
context_id=self.context_id,
)
console.print(f"[{agent_name}]: {response}")
except Exception as exc:
console.print(f"[red]Failed to delegate file:[/red] {exc}")
finally:
self.background_tasks.discard(asyncio.current_task())
task = asyncio.create_task(_delegate())
self.background_tasks.add(task)
console.print("[dim]Delegation in progress… you can continue working.[/dim]")
async def cmd_quit(self, args: str = "") -> None:
"""Exit the CLI"""
console.print("\n[green]Shutting down...[/green]")
await self.agent.cleanup()
if self.background_tasks:
for task in list(self.background_tasks):
task.cancel()
await asyncio.gather(*self.background_tasks, return_exceptions=True)
console.print("Goodbye!\n")
sys.exit(0)
async def process_command(self, text: str) -> bool:
"""Process slash commands"""
if not text.startswith('/'):
return False
parts = text.split(maxsplit=1)
cmd = parts[0].lower()
args = parts[1] if len(parts) > 1 else ""
if cmd in self.commands:
await self.commands[cmd](args)
return True
console.print(f"Unknown command: {cmd}")
return True
async def auto_register_agents(self):
"""Auto-register agents from config on startup"""
agents_to_register = self.config_manager.get_registered_agents()
if agents_to_register:
console.print(f"\n[dim]Auto-registering {len(agents_to_register)} agents from config...[/dim]")
for agent_config in agents_to_register:
url = agent_config.get('url')
name = agent_config.get('name', 'Unknown')
if url:
try:
with safe_status(f"Registering {name}..."):
result = await self.agent.register_agent(url)
if result["success"]:
console.print(f"{name}: [green]Connected[/green]")
else:
console.print(f" ⚠️ {name}: [yellow]Failed - {result.get('error', 'Unknown error')}[/yellow]")
except Exception as e:
console.print(f" ⚠️ {name}: [yellow]Failed - {e}[/yellow]")
console.print("") # Empty line for spacing
async def run(self):
"""Main CLI loop"""
self.print_banner()
# Auto-register agents from config
await self.auto_register_agents()
while not shutdown_requested:
try:
# Use standard input with non-deletable colored prompt
prompt_symbol = get_prompt_symbol()
try:
# Print colored prompt then use input() for non-deletable behavior
console.print(f"[medium_purple3]{prompt_symbol}[/medium_purple3] ", end="")
user_input = input().strip()
except (EOFError, KeyboardInterrupt):
raise
if not user_input:
continue
# Check for commands
if await self.process_command(user_input):
continue
# Process message
with safe_status(get_dynamic_status('thinking')):
response = await self.agent.process_message(user_input, self.context_id)
# Display response
console.print(f"\n{response}\n")
except KeyboardInterrupt:
await self.cmd_quit()
except EOFError:
await self.cmd_quit()
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
if os.getenv('FUZZFORGE_DEBUG') == '1':
console.print_exception()
console.print("")
await self.agent.cleanup()
def main():
"""Main entry point"""
try:
cli = FuzzForgeCLI()
asyncio.run(cli.run())
except KeyboardInterrupt:
console.print("\n[yellow]Interrupted[/yellow]")
sys.exit(0)
except Exception as e:
console.print(f"[red]Fatal error: {e}[/red]")
if os.getenv('FUZZFORGE_DEBUG') == '1':
console.print_exception()
sys.exit(1)
if __name__ == "__main__":
main()
+435
View File
@@ -0,0 +1,435 @@
"""
Cognee Integration Module for FuzzForge
Provides standardized access to project-specific knowledge graphs
Can be reused by external agents and other components
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import os
import asyncio
import json
from typing import Dict, List, Any, Optional, Union
from pathlib import Path
class CogneeProjectIntegration:
"""
Standardized Cognee integration that can be reused across agents
Automatically detects project context and provides knowledge graph access
"""
def __init__(self, project_dir: Optional[str] = None):
"""
Initialize with project directory (defaults to current working directory)
Args:
project_dir: Path to project directory (optional, defaults to cwd)
"""
self.project_dir = Path(project_dir) if project_dir else Path.cwd()
self.config_file = self.project_dir / ".fuzzforge" / "config.yaml"
self.project_context = None
self._cognee = None
self._initialized = False
async def initialize(self) -> bool:
"""
Initialize Cognee with project context
Returns:
bool: True if initialization successful
"""
try:
# Import Cognee
import cognee
self._cognee = cognee
# Load project context
if not self._load_project_context():
return False
# Configure Cognee for this project
await self._setup_cognee_config()
self._initialized = True
return True
except ImportError:
print("Cognee not installed. Install with: pip install cognee")
return False
except Exception as e:
print(f"Failed to initialize Cognee: {e}")
return False
def _load_project_context(self) -> bool:
"""Load project context from FuzzForge config"""
try:
if not self.config_file.exists():
print(f"No FuzzForge config found at {self.config_file}")
return False
import yaml
with open(self.config_file, 'r') as f:
config = yaml.safe_load(f)
self.project_context = {
"project_name": config.get("project", {}).get("name", "default"),
"project_id": config.get("project", {}).get("id", "default"),
"tenant_id": config.get("cognee", {}).get("tenant", "default")
}
return True
except Exception as e:
print(f"Error loading project context: {e}")
return False
async def _setup_cognee_config(self):
"""Configure Cognee for project-specific access"""
# Set API key and model
api_key = os.getenv('OPENAI_API_KEY')
model = os.getenv('LITELLM_MODEL', 'gpt-4o-mini')
if not api_key:
raise ValueError("OPENAI_API_KEY required for Cognee operations")
# Configure Cognee
self._cognee.config.set_llm_api_key(api_key)
self._cognee.config.set_llm_model(model)
self._cognee.config.set_llm_provider("openai")
# Set project-specific directories
project_cognee_dir = self.project_dir / ".fuzzforge" / "cognee" / f"project_{self.project_context['project_id']}"
self._cognee.config.data_root_directory(str(project_cognee_dir / "data"))
self._cognee.config.system_root_directory(str(project_cognee_dir / "system"))
# Ensure directories exist
project_cognee_dir.mkdir(parents=True, exist_ok=True)
(project_cognee_dir / "data").mkdir(exist_ok=True)
(project_cognee_dir / "system").mkdir(exist_ok=True)
async def search_knowledge_graph(self, query: str, search_type: str = "GRAPH_COMPLETION", dataset: str = None) -> Dict[str, Any]:
"""
Search the project's knowledge graph
Args:
query: Search query
search_type: Type of search ("GRAPH_COMPLETION", "INSIGHTS", "CHUNKS", etc.)
dataset: Specific dataset to search (optional)
Returns:
Dict containing search results
"""
if not self._initialized:
await self.initialize()
if not self._initialized:
return {"error": "Cognee not initialized"}
try:
from cognee.modules.search.types import SearchType
# Resolve search type dynamically; fallback to GRAPH_COMPLETION
try:
search_type_enum = getattr(SearchType, search_type.upper())
except AttributeError:
search_type_enum = SearchType.GRAPH_COMPLETION
search_type = "GRAPH_COMPLETION"
# Prepare search kwargs
search_kwargs = {
"query_type": search_type_enum,
"query_text": query
}
# Add dataset filter if specified
if dataset:
search_kwargs["datasets"] = [dataset]
results = await self._cognee.search(**search_kwargs)
return {
"query": query,
"search_type": search_type,
"dataset": dataset,
"results": results,
"project": self.project_context["project_name"]
}
except Exception as e:
return {"error": f"Search failed: {e}"}
async def list_knowledge_data(self) -> Dict[str, Any]:
"""
List available data in the knowledge graph
Returns:
Dict containing available data
"""
if not self._initialized:
await self.initialize()
if not self._initialized:
return {"error": "Cognee not initialized"}
try:
data = await self._cognee.list_data()
return {
"project": self.project_context["project_name"],
"available_data": data
}
except Exception as e:
return {"error": f"Failed to list data: {e}"}
async def ingest_text_to_dataset(self, text: str, dataset: str = None) -> Dict[str, Any]:
"""
Ingest text content into a specific dataset
Args:
text: Text to ingest
dataset: Dataset name (defaults to project_name_codebase)
Returns:
Dict containing ingest results
"""
if not self._initialized:
await self.initialize()
if not self._initialized:
return {"error": "Cognee not initialized"}
if not dataset:
dataset = f"{self.project_context['project_name']}_codebase"
try:
# Add text to dataset
await self._cognee.add([text], dataset_name=dataset)
# Process (cognify) the dataset
await self._cognee.cognify([dataset])
return {
"text_length": len(text),
"dataset": dataset,
"project": self.project_context["project_name"],
"status": "success"
}
except Exception as e:
return {"error": f"Ingest failed: {e}"}
async def ingest_files_to_dataset(self, file_paths: list, dataset: str = None) -> Dict[str, Any]:
"""
Ingest multiple files into a specific dataset
Args:
file_paths: List of file paths to ingest
dataset: Dataset name (defaults to project_name_codebase)
Returns:
Dict containing ingest results
"""
if not self._initialized:
await self.initialize()
if not self._initialized:
return {"error": "Cognee not initialized"}
if not dataset:
dataset = f"{self.project_context['project_name']}_codebase"
try:
# Validate and filter readable files
valid_files = []
for file_path in file_paths:
try:
path = Path(file_path)
if path.exists() and path.is_file():
# Test if file is readable
with open(path, 'r', encoding='utf-8') as f:
f.read(1)
valid_files.append(str(path))
except (UnicodeDecodeError, PermissionError, OSError):
continue
if not valid_files:
return {"error": "No valid files found to ingest"}
# Add files to dataset
await self._cognee.add(valid_files, dataset_name=dataset)
# Process (cognify) the dataset
await self._cognee.cognify([dataset])
return {
"files_processed": len(valid_files),
"total_files_requested": len(file_paths),
"dataset": dataset,
"project": self.project_context["project_name"],
"status": "success"
}
except Exception as e:
return {"error": f"Ingest failed: {e}"}
async def list_datasets(self) -> Dict[str, Any]:
"""
List all datasets available in the project
Returns:
Dict containing available datasets
"""
if not self._initialized:
await self.initialize()
if not self._initialized:
return {"error": "Cognee not initialized"}
try:
# Get available datasets by searching for data
data = await self._cognee.list_data()
# Extract unique dataset names from the data
datasets = set()
if isinstance(data, list):
for item in data:
if isinstance(item, dict) and 'dataset_name' in item:
datasets.add(item['dataset_name'])
return {
"project": self.project_context["project_name"],
"datasets": list(datasets),
"total_datasets": len(datasets)
}
except Exception as e:
return {"error": f"Failed to list datasets: {e}"}
async def create_dataset(self, dataset: str) -> Dict[str, Any]:
"""
Create a new dataset (dataset is created automatically when data is added)
Args:
dataset: Dataset name to create
Returns:
Dict containing creation result
"""
if not self._initialized:
await self.initialize()
if not self._initialized:
return {"error": "Cognee not initialized"}
try:
# In Cognee, datasets are created implicitly when data is added
# We'll add empty content to create the dataset
await self._cognee.add([f"Dataset {dataset} initialized for project {self.project_context['project_name']}"],
dataset_name=dataset)
return {
"dataset": dataset,
"project": self.project_context["project_name"],
"status": "created"
}
except Exception as e:
return {"error": f"Failed to create dataset: {e}"}
def get_project_context(self) -> Optional[Dict[str, str]]:
"""Get current project context"""
return self.project_context
def is_initialized(self) -> bool:
"""Check if Cognee is initialized"""
return self._initialized
# Convenience functions for easy integration
async def search_project_codebase(query: str, project_dir: Optional[str] = None, dataset: str = None, search_type: str = "GRAPH_COMPLETION") -> str:
"""
Convenience function to search project codebase
Args:
query: Search query
project_dir: Project directory (optional, defaults to cwd)
dataset: Specific dataset to search (optional)
search_type: Type of search ("GRAPH_COMPLETION", "INSIGHTS", "CHUNKS")
Returns:
Formatted search results as string
"""
cognee_integration = CogneeProjectIntegration(project_dir)
result = await cognee_integration.search_knowledge_graph(query, search_type, dataset)
if "error" in result:
return f"Error searching codebase: {result['error']}"
project_name = result.get("project", "Unknown")
results = result.get("results", [])
if not results:
return f"No results found for '{query}' in project {project_name}"
output = f"Search results for '{query}' in project {project_name}:\n\n"
# Format results
if isinstance(results, list):
for i, item in enumerate(results, 1):
if isinstance(item, dict):
# Handle structured results
output += f"{i}. "
if "search_result" in item:
output += f"Dataset: {item.get('dataset_name', 'Unknown')}\n"
for result_item in item["search_result"]:
if isinstance(result_item, dict):
if "name" in result_item:
output += f" - {result_item['name']}: {result_item.get('description', '')}\n"
elif "text" in result_item:
text = result_item["text"][:200] + "..." if len(result_item["text"]) > 200 else result_item["text"]
output += f" - {text}\n"
else:
output += f" - {str(result_item)[:200]}...\n"
else:
output += f"{str(item)[:200]}...\n"
output += "\n"
else:
output += f"{i}. {str(item)[:200]}...\n\n"
else:
output += f"{str(results)[:500]}..."
return output
async def list_project_knowledge(project_dir: Optional[str] = None) -> str:
"""
Convenience function to list project knowledge
Args:
project_dir: Project directory (optional, defaults to cwd)
Returns:
Formatted list of available data
"""
cognee_integration = CogneeProjectIntegration(project_dir)
result = await cognee_integration.list_knowledge_data()
if "error" in result:
return f"Error listing knowledge: {result['error']}"
project_name = result.get("project", "Unknown")
data = result.get("available_data", [])
output = f"Available knowledge in project {project_name}:\n\n"
if not data:
output += "No data available in knowledge graph"
else:
for i, item in enumerate(data, 1):
output += f"{i}. {item}\n"
return output
+416
View File
@@ -0,0 +1,416 @@
"""
Cognee Service for FuzzForge
Provides integrated Cognee functionality for codebase analysis and knowledge graphs
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import os
import asyncio
import logging
from pathlib import Path
from typing import Dict, List, Any, Optional
from datetime import datetime
logger = logging.getLogger(__name__)
class CogneeService:
"""
Service for managing Cognee integration with FuzzForge
Handles multi-tenant isolation and project-specific knowledge graphs
"""
def __init__(self, config):
"""Initialize with FuzzForge config"""
self.config = config
self.cognee_config = config.get_cognee_config()
self.project_context = config.get_project_context()
self._cognee = None
self._user = None
self._initialized = False
async def initialize(self):
"""Initialize Cognee with project-specific configuration"""
try:
# Ensure environment variables for Cognee are set before import
self.config.setup_cognee_environment()
logger.debug(
"Cognee environment configured",
extra={
"data": self.cognee_config.get("data_directory"),
"system": self.cognee_config.get("system_directory"),
},
)
import cognee
self._cognee = cognee
# Configure LLM with API key BEFORE any other cognee operations
provider = os.getenv("LLM_PROVIDER", "openai")
model = os.getenv("LLM_MODEL") or os.getenv("LITELLM_MODEL", "gpt-4o-mini")
api_key = os.getenv("LLM_API_KEY") or os.getenv("OPENAI_API_KEY")
endpoint = os.getenv("LLM_ENDPOINT")
api_version = os.getenv("LLM_API_VERSION")
max_tokens = os.getenv("LLM_MAX_TOKENS")
if provider.lower() in {"openai", "azure_openai", "custom"} and not api_key:
raise ValueError(
"OpenAI-compatible API key is required for Cognee LLM operations. "
"Set OPENAI_API_KEY, LLM_API_KEY, or COGNEE_LLM_API_KEY in your .env"
)
# Expose environment variables for downstream libraries
os.environ["LLM_PROVIDER"] = provider
os.environ["LITELLM_MODEL"] = model
os.environ["LLM_MODEL"] = model
if api_key:
os.environ["LLM_API_KEY"] = api_key
# Maintain compatibility with components still expecting OPENAI_API_KEY
if provider.lower() in {"openai", "azure_openai", "custom"}:
os.environ.setdefault("OPENAI_API_KEY", api_key)
if endpoint:
os.environ["LLM_ENDPOINT"] = endpoint
if api_version:
os.environ["LLM_API_VERSION"] = api_version
if max_tokens:
os.environ["LLM_MAX_TOKENS"] = str(max_tokens)
# Configure Cognee's runtime using its configuration helpers when available
if hasattr(cognee.config, "set_llm_provider"):
cognee.config.set_llm_provider(provider)
if hasattr(cognee.config, "set_llm_model"):
cognee.config.set_llm_model(model)
if api_key and hasattr(cognee.config, "set_llm_api_key"):
cognee.config.set_llm_api_key(api_key)
if endpoint and hasattr(cognee.config, "set_llm_endpoint"):
cognee.config.set_llm_endpoint(endpoint)
if api_version and hasattr(cognee.config, "set_llm_api_version"):
cognee.config.set_llm_api_version(api_version)
if max_tokens and hasattr(cognee.config, "set_llm_max_tokens"):
cognee.config.set_llm_max_tokens(int(max_tokens))
# Configure graph database
cognee.config.set_graph_db_config({
"graph_database_provider": self.cognee_config.get("graph_database_provider", "kuzu"),
})
# Set data directories
data_dir = self.cognee_config.get("data_directory")
system_dir = self.cognee_config.get("system_directory")
if data_dir:
logger.debug("Setting cognee data root", extra={"path": data_dir})
cognee.config.data_root_directory(data_dir)
if system_dir:
logger.debug("Setting cognee system root", extra={"path": system_dir})
cognee.config.system_root_directory(system_dir)
# Setup multi-tenant user context
await self._setup_user_context()
self._initialized = True
logger.info(f"Cognee initialized for project {self.project_context['project_name']} "
f"with Kuzu at {system_dir}")
except ImportError:
logger.error("Cognee not installed. Install with: pip install cognee")
raise
except Exception as e:
logger.error(f"Failed to initialize Cognee: {e}")
raise
async def create_dataset(self):
"""Create dataset for this project if it doesn't exist"""
if not self._initialized:
await self.initialize()
try:
# Dataset creation is handled automatically by Cognee when adding files
# We just ensure we have the right context set up
dataset_name = f"{self.project_context['project_name']}_codebase"
logger.info(f"Dataset {dataset_name} ready for project {self.project_context['project_name']}")
return dataset_name
except Exception as e:
logger.error(f"Failed to create dataset: {e}")
raise
async def _setup_user_context(self):
"""Setup user context for multi-tenant isolation"""
try:
from cognee.modules.users.methods import create_user, get_user
# Always try fallback email first to avoid validation issues
fallback_email = f"project_{self.project_context['project_id']}@fuzzforge.example"
user_tenant = self.project_context['tenant_id']
# Try to get existing fallback user first
try:
self._user = await get_user(fallback_email)
logger.info(f"Using existing user: {fallback_email}")
return
except:
# User doesn't exist, try to create fallback
pass
# Create fallback user
try:
self._user = await create_user(fallback_email, user_tenant)
logger.info(f"Created fallback user: {fallback_email} for tenant: {user_tenant}")
return
except Exception as fallback_error:
logger.warning(f"Fallback user creation failed: {fallback_error}")
self._user = None
return
except Exception as e:
logger.warning(f"Could not setup multi-tenant user context: {e}")
logger.info("Proceeding with default context")
self._user = None
def get_project_dataset_name(self, dataset_suffix: str = "codebase") -> str:
"""Get project-specific dataset name"""
return f"{self.project_context['project_name']}_{dataset_suffix}"
async def ingest_text(self, content: str, dataset: str = "fuzzforge") -> bool:
"""Ingest text content into knowledge graph"""
if not self._initialized:
await self.initialize()
try:
await self._cognee.add([content], dataset)
await self._cognee.cognify([dataset])
return True
except Exception as e:
logger.error(f"Failed to ingest text: {e}")
return False
async def ingest_files(self, file_paths: List[Path], dataset: str = "fuzzforge") -> Dict[str, Any]:
"""Ingest multiple files into knowledge graph"""
if not self._initialized:
await self.initialize()
results = {
"success": 0,
"failed": 0,
"errors": []
}
try:
ingest_paths: List[str] = []
for file_path in file_paths:
try:
with open(file_path, 'r', encoding='utf-8'):
ingest_paths.append(str(file_path))
results["success"] += 1
except (UnicodeDecodeError, PermissionError) as exc:
results["failed"] += 1
results["errors"].append(f"{file_path}: {exc}")
logger.warning("Skipping %s: %s", file_path, exc)
if ingest_paths:
await self._cognee.add(ingest_paths, dataset_name=dataset)
await self._cognee.cognify([dataset])
except Exception as e:
logger.error(f"Failed to ingest files: {e}")
results["errors"].append(f"Cognify error: {str(e)}")
return results
async def search_insights(self, query: str, dataset: str = None) -> List[str]:
"""Search for insights in the knowledge graph"""
if not self._initialized:
await self.initialize()
try:
from cognee.modules.search.types import SearchType
kwargs = {
"query_type": SearchType.INSIGHTS,
"query_text": query
}
if dataset:
kwargs["datasets"] = [dataset]
results = await self._cognee.search(**kwargs)
return results if isinstance(results, list) else []
except Exception as e:
logger.error(f"Failed to search insights: {e}")
return []
async def search_chunks(self, query: str, dataset: str = None) -> List[str]:
"""Search for relevant text chunks"""
if not self._initialized:
await self.initialize()
try:
from cognee.modules.search.types import SearchType
kwargs = {
"query_type": SearchType.CHUNKS,
"query_text": query
}
if dataset:
kwargs["datasets"] = [dataset]
results = await self._cognee.search(**kwargs)
return results if isinstance(results, list) else []
except Exception as e:
logger.error(f"Failed to search chunks: {e}")
return []
async def search_graph_completion(self, query: str) -> List[str]:
"""Search for graph completion (relationships)"""
if not self._initialized:
await self.initialize()
try:
from cognee.modules.search.types import SearchType
results = await self._cognee.search(
query_type=SearchType.GRAPH_COMPLETION,
query_text=query
)
return results if isinstance(results, list) else []
except Exception as e:
logger.error(f"Failed to search graph completion: {e}")
return []
async def get_status(self) -> Dict[str, Any]:
"""Get service status and statistics"""
status = {
"initialized": self._initialized,
"enabled": self.cognee_config.get("enabled", True),
"provider": self.cognee_config.get("graph_database_provider", "kuzu"),
"data_directory": self.cognee_config.get("data_directory"),
"system_directory": self.cognee_config.get("system_directory"),
}
if self._initialized:
try:
# Check if directories exist and get sizes
data_dir = Path(status["data_directory"])
system_dir = Path(status["system_directory"])
status.update({
"data_dir_exists": data_dir.exists(),
"system_dir_exists": system_dir.exists(),
"kuzu_db_exists": (system_dir / "kuzu_db").exists(),
"lancedb_exists": (system_dir / "lancedb").exists(),
})
except Exception as e:
status["status_error"] = str(e)
return status
async def clear_data(self, confirm: bool = False):
"""Clear all ingested data (dangerous!)"""
if not confirm:
raise ValueError("Must confirm data clearing with confirm=True")
if not self._initialized:
await self.initialize()
try:
await self._cognee.prune.prune_data()
await self._cognee.prune.prune_system(metadata=True)
logger.info("Cognee data cleared")
except Exception as e:
logger.error(f"Failed to clear data: {e}")
raise
class FuzzForgeCogneeIntegration:
"""
Main integration class for FuzzForge + Cognee
Provides high-level operations for security analysis
"""
def __init__(self, config):
self.service = CogneeService(config)
async def analyze_codebase(self, path: Path, recursive: bool = True) -> Dict[str, Any]:
"""
Analyze a codebase and extract security-relevant insights
"""
# Collect code files
from fuzzforge_ai.ingest_utils import collect_ingest_files
files = collect_ingest_files(path, recursive, None, [])
if not files:
return {"error": "No files found to analyze"}
# Ingest files
results = await self.service.ingest_files(files, "security_analysis")
if results["success"] == 0:
return {"error": "Failed to ingest any files", "details": results}
# Extract security insights
security_queries = [
"vulnerabilities security risks",
"authentication authorization",
"input validation sanitization",
"encryption cryptography",
"error handling exceptions",
"logging sensitive data"
]
insights = {}
for query in security_queries:
insight_results = await self.service.search_insights(query, "security_analysis")
if insight_results:
insights[query.replace(" ", "_")] = insight_results
return {
"files_processed": results["success"],
"files_failed": results["failed"],
"errors": results["errors"],
"security_insights": insights
}
async def query_codebase(self, query: str, search_type: str = "insights") -> List[str]:
"""Query the ingested codebase"""
if search_type == "insights":
return await self.service.search_insights(query)
elif search_type == "chunks":
return await self.service.search_chunks(query)
elif search_type == "graph":
return await self.service.search_graph_completion(query)
else:
raise ValueError(f"Unknown search type: {search_type}")
async def get_project_summary(self) -> Dict[str, Any]:
"""Get a summary of the analyzed project"""
# Search for general project insights
summary_queries = [
"project structure components",
"main functionality features",
"programming languages frameworks",
"dependencies libraries"
]
summary = {}
for query in summary_queries:
results = await self.service.search_insights(query)
if results:
summary[query.replace(" ", "_")] = results[:3] # Top 3 results
return summary
+9
View File
@@ -0,0 +1,9 @@
# FuzzForge Registered Agents
# These agents will be automatically registered on startup
registered_agents:
# Example entries:
# - name: Calculator
# url: http://localhost:10201
# description: Mathematical calculations agent
+31
View File
@@ -0,0 +1,31 @@
"""Bridge module providing access to the host CLI configuration manager."""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
try:
from fuzzforge_cli.config import ProjectConfigManager as _ProjectConfigManager
except ImportError as exc: # pragma: no cover - used when CLI not available
class _ProjectConfigManager: # type: ignore[no-redef]
"""Fallback implementation that raises a helpful error."""
def __init__(self, *args, **kwargs):
raise ImportError(
"ProjectConfigManager is unavailable. Install the FuzzForge CLI "
"package or supply a compatible configuration object."
) from exc
def __getattr__(name): # pragma: no cover - defensive
raise ImportError("ProjectConfigManager unavailable") from exc
ProjectConfigManager = _ProjectConfigManager
__all__ = ["ProjectConfigManager"]
+134
View File
@@ -0,0 +1,134 @@
"""
Configuration manager for FuzzForge
Handles loading and saving registered agents
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import os
import yaml
from typing import Dict, Any, List
class ConfigManager:
"""Manages FuzzForge agent registry configuration"""
def __init__(self, config_path: str = None):
"""Initialize config manager"""
if config_path:
self.config_path = config_path
else:
# Check for local .fuzzforge/agents.yaml first, then fall back to global
local_config = os.path.join(os.getcwd(), '.fuzzforge', 'agents.yaml')
global_config = os.path.join(os.path.dirname(__file__), 'config.yaml')
if os.path.exists(local_config):
self.config_path = local_config
if os.getenv("FUZZFORGE_DEBUG", "0") == "1":
print(f"[CONFIG] Using local config: {local_config}")
else:
self.config_path = global_config
if os.getenv("FUZZFORGE_DEBUG", "0") == "1":
print(f"[CONFIG] Using global config: {global_config}")
self.config = self.load_config()
def load_config(self) -> Dict[str, Any]:
"""Load configuration from YAML file"""
if not os.path.exists(self.config_path):
# Create default config if it doesn't exist
return {'registered_agents': []}
try:
with open(self.config_path, 'r') as f:
config = yaml.safe_load(f) or {}
# Ensure registered_agents is a list
if 'registered_agents' not in config or config['registered_agents'] is None:
config['registered_agents'] = []
return config
except Exception as e:
print(f"[WARNING] Failed to load config: {e}")
return {'registered_agents': []}
def save_config(self):
"""Save current configuration to file"""
try:
# Create a clean config with comments
config_content = """# FuzzForge Registered Agents
# These agents will be automatically registered on startup
"""
# Add the agents list
if self.config.get('registered_agents'):
config_content += yaml.dump({'registered_agents': self.config['registered_agents']},
default_flow_style=False, sort_keys=False)
else:
config_content += "registered_agents: []\n"
config_content += """
# Example entries:
# - name: Calculator
# url: http://localhost:10201
# description: Mathematical calculations agent
"""
with open(self.config_path, 'w') as f:
f.write(config_content)
return True
except Exception as e:
print(f"[ERROR] Failed to save config: {e}")
return False
def get_registered_agents(self) -> List[Dict[str, Any]]:
"""Get list of registered agents from config"""
return self.config.get('registered_agents', [])
def add_registered_agent(self, name: str, url: str, description: str = "") -> bool:
"""Add a new registered agent to config"""
if 'registered_agents' not in self.config:
self.config['registered_agents'] = []
# Check if agent already exists
for agent in self.config['registered_agents']:
if agent.get('url') == url:
# Update existing agent
agent['name'] = name
agent['description'] = description
return self.save_config()
# Add new agent
self.config['registered_agents'].append({
'name': name,
'url': url,
'description': description
})
return self.save_config()
def remove_registered_agent(self, name: str = None, url: str = None) -> bool:
"""Remove a registered agent from config"""
if 'registered_agents' not in self.config:
return False
original_count = len(self.config['registered_agents'])
# Filter out the agent
self.config['registered_agents'] = [
agent for agent in self.config['registered_agents']
if not ((name and agent.get('name') == name) or
(url and agent.get('url') == url))
]
if len(self.config['registered_agents']) < original_count:
return self.save_config()
return False
+104
View File
@@ -0,0 +1,104 @@
"""Utilities for collecting files to ingest into Cognee."""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
from __future__ import annotations
import fnmatch
from pathlib import Path
from typing import Iterable, List, Optional
_DEFAULT_FILE_TYPES = [
".py",
".js",
".ts",
".java",
".cpp",
".c",
".h",
".rs",
".go",
".rb",
".php",
".cs",
".swift",
".kt",
".scala",
".clj",
".hs",
".md",
".txt",
".yaml",
".yml",
".json",
".toml",
".cfg",
".ini",
]
_DEFAULT_EXCLUDE = [
"*.pyc",
"__pycache__",
".git",
".svn",
".hg",
"node_modules",
".venv",
"venv",
".env",
"dist",
"build",
".pytest_cache",
".mypy_cache",
".tox",
"coverage",
"*.log",
"*.tmp",
]
def collect_ingest_files(
path: Path,
recursive: bool = True,
file_types: Optional[Iterable[str]] = None,
exclude: Optional[Iterable[str]] = None,
) -> List[Path]:
"""Return a list of files eligible for ingestion."""
path = path.resolve()
files: List[Path] = []
extensions = list(file_types) if file_types else list(_DEFAULT_FILE_TYPES)
exclusions = list(exclude) if exclude else []
exclusions.extend(_DEFAULT_EXCLUDE)
def should_exclude(file_path: Path) -> bool:
file_str = str(file_path)
for pattern in exclusions:
if fnmatch.fnmatch(file_str, f"*{pattern}*") or fnmatch.fnmatch(file_path.name, pattern):
return True
return False
if path.is_file():
if not should_exclude(path) and any(str(path).endswith(ext) for ext in extensions):
files.append(path)
return files
pattern = "**/*" if recursive else "*"
for file_path in path.glob(pattern):
if file_path.is_file() and not should_exclude(file_path):
if any(str(file_path).endswith(ext) for ext in extensions):
files.append(file_path)
return files
__all__ = ["collect_ingest_files"]
+247
View File
@@ -0,0 +1,247 @@
"""
FuzzForge Memory Service
Implements ADK MemoryService pattern for conversational memory
Separate from Cognee which will be used for RAG/codebase analysis
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import os
import json
from typing import Dict, List, Any, Optional
from datetime import datetime
import logging
# ADK Memory imports
from google.adk.memory import InMemoryMemoryService, BaseMemoryService
from google.adk.memory.base_memory_service import SearchMemoryResponse
from google.adk.memory.memory_entry import MemoryEntry
# Optional VertexAI Memory Bank
try:
from google.adk.memory import VertexAiMemoryBankService
VERTEX_AVAILABLE = True
except ImportError:
VERTEX_AVAILABLE = False
logger = logging.getLogger(__name__)
class FuzzForgeMemoryService:
"""
Manages conversational memory using ADK patterns
This is separate from Cognee which will handle RAG/codebase
"""
def __init__(self, memory_type: str = "inmemory", **kwargs):
"""
Initialize memory service
Args:
memory_type: "inmemory" or "vertexai"
**kwargs: Additional args for specific memory service
For vertexai: project, location, agent_engine_id
"""
self.memory_type = memory_type
self.service = self._create_service(memory_type, **kwargs)
def _create_service(self, memory_type: str, **kwargs) -> BaseMemoryService:
"""Create the appropriate memory service"""
if memory_type == "inmemory":
# Use ADK's InMemoryMemoryService for local development
logger.info("Using InMemory MemoryService for conversational memory")
return InMemoryMemoryService()
elif memory_type == "vertexai" and VERTEX_AVAILABLE:
# Use VertexAI Memory Bank for production
project = kwargs.get('project') or os.getenv('GOOGLE_CLOUD_PROJECT')
location = kwargs.get('location') or os.getenv('GOOGLE_CLOUD_LOCATION', 'us-central1')
agent_engine_id = kwargs.get('agent_engine_id') or os.getenv('AGENT_ENGINE_ID')
if not all([project, location, agent_engine_id]):
logger.warning("VertexAI config missing, falling back to InMemory")
return InMemoryMemoryService()
logger.info(f"Using VertexAI MemoryBank: {agent_engine_id}")
return VertexAiMemoryBankService(
project=project,
location=location,
agent_engine_id=agent_engine_id
)
else:
# Default to in-memory
logger.info("Defaulting to InMemory MemoryService")
return InMemoryMemoryService()
async def add_session_to_memory(self, session: Any) -> None:
"""
Add a completed session to long-term memory
This extracts meaningful information from the conversation
Args:
session: The session object to process
"""
try:
# Let the underlying service handle the ingestion
# It will extract relevant information based on the implementation
await self.service.add_session_to_memory(session)
logger.debug(f"Added session {session.id} to {self.memory_type} memory")
except Exception as e:
logger.error(f"Failed to add session to memory: {e}")
async def search_memory(self,
query: str,
app_name: str = "fuzzforge",
user_id: str = None,
max_results: int = 10) -> SearchMemoryResponse:
"""
Search long-term memory for relevant information
Args:
query: The search query
app_name: Application name for filtering
user_id: User ID for filtering (optional)
max_results: Maximum number of results
Returns:
SearchMemoryResponse with relevant memories
"""
try:
# Search the memory service
results = await self.service.search_memory(
app_name=app_name,
user_id=user_id,
query=query
)
logger.debug(f"Memory search for '{query}' returned {len(results.memories)} results")
return results
except Exception as e:
logger.error(f"Memory search failed: {e}")
# Return empty results on error
return SearchMemoryResponse(memories=[])
async def ingest_completed_sessions(self, session_service) -> int:
"""
Batch ingest all completed sessions into memory
Useful for initial memory population
Args:
session_service: The session service containing sessions
Returns:
Number of sessions ingested
"""
ingested = 0
try:
# Get all sessions from the session service
sessions = await session_service.list_sessions(app_name="fuzzforge")
for session_info in sessions:
# Load full session
session = await session_service.load_session(
app_name="fuzzforge",
user_id=session_info.get('user_id'),
session_id=session_info.get('id')
)
if session and len(session.get_events()) > 0:
await self.add_session_to_memory(session)
ingested += 1
logger.info(f"Ingested {ingested} sessions into {self.memory_type} memory")
except Exception as e:
logger.error(f"Failed to batch ingest sessions: {e}")
return ingested
def get_status(self) -> Dict[str, Any]:
"""Get memory service status"""
return {
"type": self.memory_type,
"active": self.service is not None,
"vertex_available": VERTEX_AVAILABLE,
"details": {
"inmemory": "Non-persistent, keyword search",
"vertexai": "Persistent, semantic search with LLM extraction"
}.get(self.memory_type, "Unknown")
}
class HybridMemoryManager:
"""
Manages both ADK MemoryService (conversational) and Cognee (RAG/codebase)
Provides unified interface for both memory systems
"""
def __init__(self,
memory_service: FuzzForgeMemoryService = None,
cognee_tools = None):
"""
Initialize with both memory systems
Args:
memory_service: ADK-pattern memory for conversations
cognee_tools: Cognee MCP tools for RAG/codebase
"""
# ADK memory for conversations
self.memory_service = memory_service or FuzzForgeMemoryService()
# Cognee for knowledge graphs and RAG (future)
self.cognee_tools = cognee_tools
async def search_conversational_memory(self, query: str) -> SearchMemoryResponse:
"""Search past conversations using ADK memory"""
return await self.memory_service.search_memory(query)
async def search_knowledge_graph(self, query: str, search_type: str = "GRAPH_COMPLETION"):
"""Search Cognee knowledge graph (for RAG/codebase in future)"""
if not self.cognee_tools:
return None
try:
# Use Cognee's graph search
return await self.cognee_tools.search(
query=query,
search_type=search_type
)
except Exception as e:
logger.debug(f"Cognee search failed: {e}")
return None
async def store_in_graph(self, content: str):
"""Store in Cognee knowledge graph (for codebase analysis later)"""
if not self.cognee_tools:
return None
try:
# Use cognify to create graph structures
return await self.cognee_tools.cognify(content)
except Exception as e:
logger.debug(f"Cognee store failed: {e}")
return None
def get_status(self) -> Dict[str, Any]:
"""Get status of both memory systems"""
return {
"conversational_memory": self.memory_service.get_status(),
"knowledge_graph": {
"active": self.cognee_tools is not None,
"purpose": "RAG/codebase analysis (future)"
}
}
+148
View File
@@ -0,0 +1,148 @@
"""
Remote Agent Connection Handler
Handles A2A protocol communication with remote agents
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import httpx
import uuid
from typing import Dict, Any, Optional, List
class RemoteAgentConnection:
"""Handles A2A protocol communication with remote agents"""
def __init__(self, url: str):
"""Initialize connection to a remote agent"""
self.url = url.rstrip('/')
self.agent_card = None
self.client = httpx.AsyncClient(timeout=120.0)
self.context_id = None
async def get_agent_card(self) -> Optional[Dict[str, Any]]:
"""Get the agent card from the remote agent"""
try:
# Try new path first (A2A 0.3.0+)
response = await self.client.get(f"{self.url}/.well-known/agent-card.json")
response.raise_for_status()
self.agent_card = response.json()
return self.agent_card
except:
# Try old path for compatibility
try:
response = await self.client.get(f"{self.url}/.well-known/agent.json")
response.raise_for_status()
self.agent_card = response.json()
return self.agent_card
except Exception as e:
print(f"Failed to get agent card from {self.url}: {e}")
return None
async def send_message(self, message: str | Dict[str, Any] | List[Dict[str, Any]]) -> str:
"""Send a message to the remote agent using A2A protocol"""
try:
parts: List[Dict[str, Any]]
metadata: Dict[str, Any] | None = None
if isinstance(message, dict):
metadata = message.get("metadata") if isinstance(message.get("metadata"), dict) else None
raw_parts = message.get("parts", [])
if not raw_parts:
text_value = message.get("text") or message.get("message")
if isinstance(text_value, str):
raw_parts = [{"type": "text", "text": text_value}]
parts = [raw_part for raw_part in raw_parts if isinstance(raw_part, dict)]
elif isinstance(message, list):
parts = [part for part in message if isinstance(part, dict)]
metadata = None
else:
parts = [{"type": "text", "text": message}]
metadata = None
if not parts:
parts = [{"type": "text", "text": ""}]
# Build JSON-RPC request per A2A spec
payload = {
"jsonrpc": "2.0",
"method": "message/send",
"params": {
"message": {
"messageId": str(uuid.uuid4()),
"role": "user",
"parts": parts,
}
},
"id": 1
}
if metadata:
payload["params"]["message"]["metadata"] = metadata
# Include context if we have one
if self.context_id:
payload["params"]["contextId"] = self.context_id
# Send to root endpoint per A2A protocol
response = await self.client.post(f"{self.url}/", json=payload)
response.raise_for_status()
result = response.json()
# Extract response based on A2A JSON-RPC format
if isinstance(result, dict):
# Update context for continuity
if "result" in result and isinstance(result["result"], dict):
if "contextId" in result["result"]:
self.context_id = result["result"]["contextId"]
# Extract text from artifacts
if "artifacts" in result["result"]:
texts = []
for artifact in result["result"]["artifacts"]:
if isinstance(artifact, dict) and "parts" in artifact:
for part in artifact["parts"]:
if isinstance(part, dict) and "text" in part:
texts.append(part["text"])
if texts:
return " ".join(texts)
# Extract from message format
if "message" in result["result"]:
msg = result["result"]["message"]
if isinstance(msg, dict) and "parts" in msg:
texts = []
for part in msg["parts"]:
if isinstance(part, dict) and "text" in part:
texts.append(part["text"])
return " ".join(texts) if texts else str(msg)
return str(msg)
return str(result["result"])
# Handle error response
elif "error" in result:
error = result["error"]
if isinstance(error, dict):
return f"Error: {error.get('message', str(error))}"
return f"Error: {error}"
# Fallback
return result.get("response", result.get("message", str(result)))
return str(result)
except Exception as e:
return f"Error communicating with agent: {e}"
async def close(self):
"""Close the connection properly"""
await self.client.aclose()
+41
View File
@@ -0,0 +1,41 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies including Docker client and rsync
RUN apt-get update && apt-get install -y \
curl \
ca-certificates \
gnupg \
lsb-release \
rsync \
&& curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null \
&& apt-get update \
&& apt-get install -y docker-ce-cli \
&& rm -rf /var/lib/apt/lists/*
# Docker client configuration removed - localhost:5001 doesn't require insecure registry config
# Install uv for faster package management
RUN pip install uv
# Copy project files
COPY pyproject.toml ./
COPY uv.lock ./
# Install dependencies
RUN uv sync --no-dev
# Copy source code
COPY . .
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# Start the application
CMD ["uv", "run", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
BIN
View File
Binary file not shown.
+122
View File
@@ -0,0 +1,122 @@
{
"name": "FuzzForge Security Testing Platform",
"description": "MCP server for FuzzForge security testing workflows via Docker Compose",
"version": "0.6.0",
"connection": {
"type": "http",
"host": "localhost",
"port": 8010,
"base_url": "http://localhost:8010",
"mcp_endpoint": "/mcp"
},
"docker_compose": {
"service": "fuzzforge-backend",
"command": "docker compose up -d",
"health_check": "http://localhost:8000/health"
},
"capabilities": {
"tools": [
{
"name": "submit_security_scan_mcp",
"description": "Submit a security scanning workflow for execution",
"parameters": {
"workflow_name": "string",
"target_path": "string",
"volume_mode": "string (ro|rw)",
"parameters": "object"
}
},
{
"name": "get_comprehensive_scan_summary",
"description": "Get a comprehensive summary of scan results with analysis",
"parameters": {
"run_id": "string"
}
}
],
"fastapi_routes": [
{
"method": "GET",
"path": "/",
"description": "Get API status and loaded workflows count"
},
{
"method": "GET",
"path": "/workflows/",
"description": "List all available security testing workflows"
},
{
"method": "POST",
"path": "/workflows/{workflow_name}/submit",
"description": "Submit a security scanning workflow for execution"
},
{
"method": "GET",
"path": "/runs/{run_id}/status",
"description": "Get the current status of a security scan run"
},
{
"method": "GET",
"path": "/runs/{run_id}/findings",
"description": "Get security findings from a completed scan"
},
{
"method": "GET",
"path": "/fuzzing/{run_id}/stats",
"description": "Get fuzzing statistics for a run"
}
]
},
"examples": {
"start_infrastructure_scan": {
"description": "Run infrastructure security scan on a project",
"steps": [
"1. Start Docker Compose: docker compose up -d",
"2. Submit scan via MCP tool: submit_security_scan_mcp",
"3. Monitor status and get results"
],
"workflow_name": "infrastructure_scan",
"target_path": "/Users/tduhamel/Documents/FuzzingLabs/fuzzforge_alpha/test_projects/infrastructure_vulnerable",
"parameters": {
"checkov_config": {
"severity": ["HIGH", "MEDIUM", "LOW"]
},
"hadolint_config": {
"severity": ["error", "warning", "info", "style"]
}
}
},
"static_analysis_scan": {
"description": "Run static analysis security scan",
"workflow_name": "static_analysis_scan",
"target_path": "/Users/tduhamel/Documents/FuzzingLabs/fuzzforge_alpha/test_projects/static_analysis_vulnerable",
"parameters": {
"bandit_config": {
"severity": ["HIGH", "MEDIUM", "LOW"]
},
"opengrep_config": {
"severity": ["HIGH", "MEDIUM", "LOW"]
}
}
},
"secret_detection_scan": {
"description": "Run secret detection scan",
"workflow_name": "secret_detection_scan",
"target_path": "/Users/tduhamel/Documents/FuzzingLabs/fuzzforge_alpha/test_projects/secret_detection_vulnerable",
"parameters": {
"trufflehog_config": {
"verified_only": false
},
"gitleaks_config": {
"no_git": true
}
}
}
},
"usage": {
"via_mcp": "Connect MCP client to http://localhost:8010/mcp after starting Docker Compose",
"via_api": "Use FastAPI endpoints directly at http://localhost:8000",
"start_system": "docker compose up -d",
"stop_system": "docker compose down"
}
}
+25
View File
@@ -0,0 +1,25 @@
[project]
name = "backend"
version = "0.6.0"
description = "FuzzForge OSS backend"
authors = []
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.116.1",
"prefect>=3.4.18",
"pydantic>=2.0.0",
"pyyaml>=6.0",
"docker>=7.0.0",
"aiofiles>=23.0.0",
"uvicorn>=0.30.0",
"aiohttp>=3.12.15",
"fastmcp",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"httpx>=0.27.0",
]
+11
View File
@@ -0,0 +1,11 @@
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
+11
View File
@@ -0,0 +1,11 @@
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
+325
View File
@@ -0,0 +1,325 @@
"""
API endpoints for fuzzing workflow management and real-time monitoring
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import logging
from typing import List, Dict, Any
from fastapi import APIRouter, HTTPException, Depends, WebSocket, WebSocketDisconnect
from fastapi.responses import StreamingResponse
import asyncio
import json
from datetime import datetime
from src.models.findings import (
FuzzingStats,
CrashReport
)
from src.core.workflow_discovery import WorkflowDiscovery
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/fuzzing", tags=["fuzzing"])
# In-memory storage for real-time stats (in production, use Redis or similar)
fuzzing_stats: Dict[str, FuzzingStats] = {}
crash_reports: Dict[str, List[CrashReport]] = {}
active_connections: Dict[str, List[WebSocket]] = {}
def initialize_fuzzing_tracking(run_id: str, workflow_name: str):
"""
Initialize fuzzing tracking for a new run.
This function should be called when a workflow is submitted to enable
real-time monitoring and stats collection.
Args:
run_id: The run identifier
workflow_name: Name of the workflow
"""
fuzzing_stats[run_id] = FuzzingStats(
run_id=run_id,
workflow=workflow_name
)
crash_reports[run_id] = []
active_connections[run_id] = []
@router.get("/{run_id}/stats", response_model=FuzzingStats)
async def get_fuzzing_stats(run_id: str) -> FuzzingStats:
"""
Get current fuzzing statistics for a run.
Args:
run_id: The fuzzing run ID
Returns:
Current fuzzing statistics
Raises:
HTTPException: 404 if run not found
"""
if run_id not in fuzzing_stats:
raise HTTPException(
status_code=404,
detail=f"Fuzzing run not found: {run_id}"
)
return fuzzing_stats[run_id]
@router.get("/{run_id}/crashes", response_model=List[CrashReport])
async def get_crash_reports(run_id: str) -> List[CrashReport]:
"""
Get crash reports for a fuzzing run.
Args:
run_id: The fuzzing run ID
Returns:
List of crash reports
Raises:
HTTPException: 404 if run not found
"""
if run_id not in crash_reports:
raise HTTPException(
status_code=404,
detail=f"Fuzzing run not found: {run_id}"
)
return crash_reports[run_id]
@router.post("/{run_id}/stats")
async def update_fuzzing_stats(run_id: str, stats: FuzzingStats):
"""
Update fuzzing statistics (called by fuzzing workflows).
Args:
run_id: The fuzzing run ID
stats: Updated statistics
Raises:
HTTPException: 404 if run not found
"""
if run_id not in fuzzing_stats:
raise HTTPException(
status_code=404,
detail=f"Fuzzing run not found: {run_id}"
)
# Update stats
fuzzing_stats[run_id] = stats
# Debug: log reception for live instrumentation
try:
logger.info(
"Received fuzzing stats update: run_id=%s exec=%s eps=%.2f crashes=%s corpus=%s elapsed=%ss",
run_id,
stats.executions,
stats.executions_per_sec,
stats.crashes,
stats.corpus_size,
stats.elapsed_time,
)
except Exception:
pass
# Notify connected WebSocket clients
if run_id in active_connections:
message = {
"type": "stats_update",
"data": stats.model_dump()
}
for websocket in active_connections[run_id][:]: # Copy to avoid modification during iteration
try:
await websocket.send_text(json.dumps(message))
except Exception:
# Remove disconnected clients
active_connections[run_id].remove(websocket)
@router.post("/{run_id}/crash")
async def report_crash(run_id: str, crash: CrashReport):
"""
Report a new crash (called by fuzzing workflows).
Args:
run_id: The fuzzing run ID
crash: Crash report details
"""
if run_id not in crash_reports:
crash_reports[run_id] = []
# Add crash report
crash_reports[run_id].append(crash)
# Update stats
if run_id in fuzzing_stats:
fuzzing_stats[run_id].crashes += 1
fuzzing_stats[run_id].last_crash_time = crash.timestamp
# Notify connected WebSocket clients
if run_id in active_connections:
message = {
"type": "crash_report",
"data": crash.model_dump()
}
for websocket in active_connections[run_id][:]:
try:
await websocket.send_text(json.dumps(message))
except Exception:
active_connections[run_id].remove(websocket)
@router.websocket("/{run_id}/live")
async def websocket_endpoint(websocket: WebSocket, run_id: str):
"""
WebSocket endpoint for real-time fuzzing updates.
Args:
websocket: WebSocket connection
run_id: The fuzzing run ID to monitor
"""
await websocket.accept()
# Initialize connection tracking
if run_id not in active_connections:
active_connections[run_id] = []
active_connections[run_id].append(websocket)
try:
# Send current stats on connection
if run_id in fuzzing_stats:
current = fuzzing_stats[run_id]
if isinstance(current, dict):
payload = current
elif hasattr(current, "model_dump"):
payload = current.model_dump()
elif hasattr(current, "dict"):
payload = current.dict()
else:
payload = getattr(current, "__dict__", {"run_id": run_id})
message = {"type": "stats_update", "data": payload}
await websocket.send_text(json.dumps(message))
# Keep connection alive
while True:
try:
# Wait for ping or handle disconnect
data = await asyncio.wait_for(websocket.receive_text(), timeout=30.0)
# Echo back for ping-pong
if data == "ping":
await websocket.send_text("pong")
except asyncio.TimeoutError:
# Send periodic heartbeat
await websocket.send_text(json.dumps({"type": "heartbeat"}))
except WebSocketDisconnect:
# Clean up connection
if run_id in active_connections and websocket in active_connections[run_id]:
active_connections[run_id].remove(websocket)
except Exception as e:
logger.error(f"WebSocket error for run {run_id}: {e}")
if run_id in active_connections and websocket in active_connections[run_id]:
active_connections[run_id].remove(websocket)
@router.get("/{run_id}/stream")
async def stream_fuzzing_updates(run_id: str):
"""
Server-Sent Events endpoint for real-time fuzzing updates.
Args:
run_id: The fuzzing run ID to monitor
Returns:
Streaming response with real-time updates
"""
if run_id not in fuzzing_stats:
raise HTTPException(
status_code=404,
detail=f"Fuzzing run not found: {run_id}"
)
async def event_stream():
"""Generate server-sent events for fuzzing updates"""
last_stats_time = datetime.utcnow()
while True:
try:
# Send current stats
if run_id in fuzzing_stats:
current_stats = fuzzing_stats[run_id]
if isinstance(current_stats, dict):
stats_payload = current_stats
elif hasattr(current_stats, "model_dump"):
stats_payload = current_stats.model_dump()
elif hasattr(current_stats, "dict"):
stats_payload = current_stats.dict()
else:
stats_payload = getattr(current_stats, "__dict__", {"run_id": run_id})
event_data = f"data: {json.dumps({'type': 'stats', 'data': stats_payload})}\n\n"
yield event_data
# Send recent crashes
if run_id in crash_reports:
recent_crashes = [
crash for crash in crash_reports[run_id]
if crash.timestamp > last_stats_time
]
for crash in recent_crashes:
event_data = f"data: {json.dumps({'type': 'crash', 'data': crash.model_dump()})}\n\n"
yield event_data
last_stats_time = datetime.utcnow()
await asyncio.sleep(5) # Update every 5 seconds
except Exception as e:
logger.error(f"Error in event stream for run {run_id}: {e}")
break
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
}
)
@router.delete("/{run_id}")
async def cleanup_fuzzing_run(run_id: str):
"""
Clean up fuzzing run data.
Args:
run_id: The fuzzing run ID to clean up
"""
# Clean up tracking data
fuzzing_stats.pop(run_id, None)
crash_reports.pop(run_id, None)
# Close any active WebSocket connections
if run_id in active_connections:
for websocket in active_connections[run_id]:
try:
await websocket.close()
except Exception:
pass
del active_connections[run_id]
return {"message": f"Cleaned up fuzzing run {run_id}"}
+184
View File
@@ -0,0 +1,184 @@
"""
API endpoints for workflow run management and findings retrieval
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import logging
from typing import Dict, Any
from fastapi import APIRouter, HTTPException, Depends
from src.models.findings import WorkflowFindings, WorkflowStatus
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/runs", tags=["runs"])
def get_prefect_manager():
"""Dependency to get the Prefect manager instance"""
from src.main import prefect_mgr
return prefect_mgr
@router.get("/{run_id}/status", response_model=WorkflowStatus)
async def get_run_status(
run_id: str,
prefect_mgr=Depends(get_prefect_manager)
) -> WorkflowStatus:
"""
Get the current status of a workflow run.
Args:
run_id: The flow run ID
Returns:
Status information including state, timestamps, and completion flags
Raises:
HTTPException: 404 if run not found
"""
try:
status = await prefect_mgr.get_flow_run_status(run_id)
# Find workflow name from deployment
workflow_name = "unknown"
workflow_deployment_id = status.get("workflow", "")
for name, deployment_id in prefect_mgr.deployments.items():
if str(deployment_id) == str(workflow_deployment_id):
workflow_name = name
break
return WorkflowStatus(
run_id=status["run_id"],
workflow=workflow_name,
status=status["status"],
is_completed=status["is_completed"],
is_failed=status["is_failed"],
is_running=status["is_running"],
created_at=status["created_at"],
updated_at=status["updated_at"]
)
except Exception as e:
logger.error(f"Failed to get status for run {run_id}: {e}")
raise HTTPException(
status_code=404,
detail=f"Run not found: {run_id}"
)
@router.get("/{run_id}/findings", response_model=WorkflowFindings)
async def get_run_findings(
run_id: str,
prefect_mgr=Depends(get_prefect_manager)
) -> WorkflowFindings:
"""
Get the findings from a completed workflow run.
Args:
run_id: The flow run ID
Returns:
SARIF-formatted findings from the workflow execution
Raises:
HTTPException: 404 if run not found, 400 if run not completed
"""
try:
# Get run status first
status = await prefect_mgr.get_flow_run_status(run_id)
if not status["is_completed"]:
if status["is_running"]:
raise HTTPException(
status_code=400,
detail=f"Run {run_id} is still running. Current status: {status['status']}"
)
elif status["is_failed"]:
raise HTTPException(
status_code=400,
detail=f"Run {run_id} failed. Status: {status['status']}"
)
else:
raise HTTPException(
status_code=400,
detail=f"Run {run_id} not completed. Status: {status['status']}"
)
# Get the findings
findings = await prefect_mgr.get_flow_run_findings(run_id)
# Find workflow name
workflow_name = "unknown"
workflow_deployment_id = status.get("workflow", "")
for name, deployment_id in prefect_mgr.deployments.items():
if str(deployment_id) == str(workflow_deployment_id):
workflow_name = name
break
# Get workflow version if available
metadata = {
"completion_time": status["updated_at"],
"workflow_version": "unknown"
}
if workflow_name in prefect_mgr.workflows:
workflow_info = prefect_mgr.workflows[workflow_name]
metadata["workflow_version"] = workflow_info.metadata.get("version", "unknown")
return WorkflowFindings(
workflow=workflow_name,
run_id=run_id,
sarif=findings,
metadata=metadata
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get findings for run {run_id}: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to retrieve findings: {str(e)}"
)
@router.get("/{workflow_name}/findings/{run_id}", response_model=WorkflowFindings)
async def get_workflow_findings(
workflow_name: str,
run_id: str,
prefect_mgr=Depends(get_prefect_manager)
) -> WorkflowFindings:
"""
Get findings for a specific workflow run.
Alternative endpoint that includes workflow name in the path for clarity.
Args:
workflow_name: Name of the workflow
run_id: The flow run ID
Returns:
SARIF-formatted findings from the workflow execution
Raises:
HTTPException: 404 if workflow or run not found, 400 if run not completed
"""
if workflow_name not in prefect_mgr.workflows:
raise HTTPException(
status_code=404,
detail=f"Workflow not found: {workflow_name}"
)
# Delegate to the main findings endpoint
return await get_run_findings(run_id, prefect_mgr)
+386
View File
@@ -0,0 +1,386 @@
"""
API endpoints for workflow management with enhanced error handling
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import logging
import traceback
from typing import List, Dict, Any, Optional
from fastapi import APIRouter, HTTPException, Depends
from pathlib import Path
from src.models.findings import (
WorkflowSubmission,
WorkflowMetadata,
WorkflowListItem,
RunSubmissionResponse
)
from src.core.workflow_discovery import WorkflowDiscovery
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/workflows", tags=["workflows"])
def create_structured_error_response(
error_type: str,
message: str,
workflow_name: Optional[str] = None,
run_id: Optional[str] = None,
container_info: Optional[Dict[str, Any]] = None,
deployment_info: Optional[Dict[str, Any]] = None,
suggestions: Optional[List[str]] = None
) -> Dict[str, Any]:
"""Create a structured error response with rich context."""
error_response = {
"error": {
"type": error_type,
"message": message,
"timestamp": __import__("datetime").datetime.utcnow().isoformat() + "Z"
}
}
if workflow_name:
error_response["error"]["workflow_name"] = workflow_name
if run_id:
error_response["error"]["run_id"] = run_id
if container_info:
error_response["error"]["container"] = container_info
if deployment_info:
error_response["error"]["deployment"] = deployment_info
if suggestions:
error_response["error"]["suggestions"] = suggestions
return error_response
def get_prefect_manager():
"""Dependency to get the Prefect manager instance"""
from src.main import prefect_mgr
return prefect_mgr
@router.get("/", response_model=List[WorkflowListItem])
async def list_workflows(
prefect_mgr=Depends(get_prefect_manager)
) -> List[WorkflowListItem]:
"""
List all discovered workflows with their metadata.
Returns a summary of each workflow including name, version, description,
author, and tags.
"""
workflows = []
for name, info in prefect_mgr.workflows.items():
workflows.append(WorkflowListItem(
name=name,
version=info.metadata.get("version", "0.6.0"),
description=info.metadata.get("description", ""),
author=info.metadata.get("author"),
tags=info.metadata.get("tags", [])
))
return workflows
@router.get("/metadata/schema")
async def get_metadata_schema() -> Dict[str, Any]:
"""
Get the JSON schema for workflow metadata files.
This schema defines the structure and requirements for metadata.yaml files
that must accompany each workflow.
"""
return WorkflowDiscovery.get_metadata_schema()
@router.get("/{workflow_name}/metadata", response_model=WorkflowMetadata)
async def get_workflow_metadata(
workflow_name: str,
prefect_mgr=Depends(get_prefect_manager)
) -> WorkflowMetadata:
"""
Get complete metadata for a specific workflow.
Args:
workflow_name: Name of the workflow
Returns:
Complete metadata including parameters schema, supported volume modes,
required modules, and more.
Raises:
HTTPException: 404 if workflow not found
"""
if workflow_name not in prefect_mgr.workflows:
available_workflows = list(prefect_mgr.workflows.keys())
error_response = create_structured_error_response(
error_type="WorkflowNotFound",
message=f"Workflow '{workflow_name}' not found",
workflow_name=workflow_name,
suggestions=[
f"Available workflows: {', '.join(available_workflows)}",
"Use GET /workflows/ to see all available workflows",
"Check workflow name spelling and case sensitivity"
]
)
raise HTTPException(
status_code=404,
detail=error_response
)
info = prefect_mgr.workflows[workflow_name]
metadata = info.metadata
return WorkflowMetadata(
name=workflow_name,
version=metadata.get("version", "0.6.0"),
description=metadata.get("description", ""),
author=metadata.get("author"),
tags=metadata.get("tags", []),
parameters=metadata.get("parameters", {}),
default_parameters=metadata.get("default_parameters", {}),
required_modules=metadata.get("required_modules", []),
supported_volume_modes=metadata.get("supported_volume_modes", ["ro", "rw"]),
has_custom_docker=info.has_docker
)
@router.post("/{workflow_name}/submit", response_model=RunSubmissionResponse)
async def submit_workflow(
workflow_name: str,
submission: WorkflowSubmission,
prefect_mgr=Depends(get_prefect_manager)
) -> RunSubmissionResponse:
"""
Submit a workflow for execution with volume mounting.
Args:
workflow_name: Name of the workflow to execute
submission: Submission parameters including target path and volume mode
Returns:
Run submission response with run_id and initial status
Raises:
HTTPException: 404 if workflow not found, 400 for invalid parameters
"""
if workflow_name not in prefect_mgr.workflows:
available_workflows = list(prefect_mgr.workflows.keys())
error_response = create_structured_error_response(
error_type="WorkflowNotFound",
message=f"Workflow '{workflow_name}' not found",
workflow_name=workflow_name,
suggestions=[
f"Available workflows: {', '.join(available_workflows)}",
"Use GET /workflows/ to see all available workflows",
"Check workflow name spelling and case sensitivity"
]
)
raise HTTPException(
status_code=404,
detail=error_response
)
try:
# Convert ResourceLimits to dict if provided
resource_limits_dict = None
if submission.resource_limits:
resource_limits_dict = {
"cpu_limit": submission.resource_limits.cpu_limit,
"memory_limit": submission.resource_limits.memory_limit,
"cpu_request": submission.resource_limits.cpu_request,
"memory_request": submission.resource_limits.memory_request
}
# Submit the workflow with enhanced parameters
flow_run = await prefect_mgr.submit_workflow(
workflow_name=workflow_name,
target_path=submission.target_path,
volume_mode=submission.volume_mode,
parameters=submission.parameters,
resource_limits=resource_limits_dict,
additional_volumes=submission.additional_volumes,
timeout=submission.timeout
)
run_id = str(flow_run.id)
# Initialize fuzzing tracking if this looks like a fuzzing workflow
workflow_info = prefect_mgr.workflows.get(workflow_name, {})
workflow_tags = workflow_info.metadata.get("tags", []) if hasattr(workflow_info, 'metadata') else []
if "fuzzing" in workflow_tags or "fuzz" in workflow_name.lower():
from src.api.fuzzing import initialize_fuzzing_tracking
initialize_fuzzing_tracking(run_id, workflow_name)
return RunSubmissionResponse(
run_id=run_id,
status=flow_run.state.name if flow_run.state else "PENDING",
workflow=workflow_name,
message=f"Workflow '{workflow_name}' submitted successfully"
)
except ValueError as e:
# Parameter validation errors
error_response = create_structured_error_response(
error_type="ValidationError",
message=str(e),
workflow_name=workflow_name,
suggestions=[
"Check parameter types and values",
"Use GET /workflows/{workflow_name}/parameters for schema",
"Ensure all required parameters are provided"
]
)
raise HTTPException(status_code=400, detail=error_response)
except Exception as e:
logger.error(f"Failed to submit workflow '{workflow_name}': {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
# Try to get more context about the error
container_info = None
deployment_info = None
suggestions = []
error_message = str(e)
error_type = "WorkflowSubmissionError"
# Detect specific error patterns
if "deployment" in error_message.lower():
error_type = "DeploymentError"
deployment_info = {
"status": "failed",
"error": error_message
}
suggestions.extend([
"Check if Prefect server is running and accessible",
"Verify Docker is running and has sufficient resources",
"Check container image availability",
"Ensure volume paths exist and are accessible"
])
elif "volume" in error_message.lower() or "mount" in error_message.lower():
error_type = "VolumeError"
suggestions.extend([
"Check if the target path exists and is accessible",
"Verify file permissions (Docker needs read access)",
"Ensure the path is not in use by another process",
"Try using an absolute path instead of relative path"
])
elif "memory" in error_message.lower() or "resource" in error_message.lower():
error_type = "ResourceError"
suggestions.extend([
"Check system memory and CPU availability",
"Consider reducing resource limits or dataset size",
"Monitor Docker resource usage",
"Increase Docker memory limits if needed"
])
elif "image" in error_message.lower():
error_type = "ImageError"
suggestions.extend([
"Check if the workflow image exists",
"Verify Docker registry access",
"Try rebuilding the workflow image",
"Check network connectivity to registries"
])
else:
suggestions.extend([
"Check FuzzForge backend logs for details",
"Verify all services are running (docker-compose up -d)",
"Try restarting the workflow deployment",
"Contact support if the issue persists"
])
error_response = create_structured_error_response(
error_type=error_type,
message=f"Failed to submit workflow: {error_message}",
workflow_name=workflow_name,
container_info=container_info,
deployment_info=deployment_info,
suggestions=suggestions
)
raise HTTPException(
status_code=500,
detail=error_response
)
@router.get("/{workflow_name}/parameters")
async def get_workflow_parameters(
workflow_name: str,
prefect_mgr=Depends(get_prefect_manager)
) -> Dict[str, Any]:
"""
Get the parameters schema for a workflow.
Args:
workflow_name: Name of the workflow
Returns:
Parameters schema with types, descriptions, and defaults
Raises:
HTTPException: 404 if workflow not found
"""
if workflow_name not in prefect_mgr.workflows:
available_workflows = list(prefect_mgr.workflows.keys())
error_response = create_structured_error_response(
error_type="WorkflowNotFound",
message=f"Workflow '{workflow_name}' not found",
workflow_name=workflow_name,
suggestions=[
f"Available workflows: {', '.join(available_workflows)}",
"Use GET /workflows/ to see all available workflows"
]
)
raise HTTPException(
status_code=404,
detail=error_response
)
info = prefect_mgr.workflows[workflow_name]
metadata = info.metadata
# Return parameters with enhanced schema information
parameters_schema = metadata.get("parameters", {})
# Extract the actual parameter definitions from JSON schema structure
if "properties" in parameters_schema:
param_definitions = parameters_schema["properties"]
else:
param_definitions = parameters_schema
# Add default values to the schema
default_params = metadata.get("default_parameters", {})
for param_name, param_schema in param_definitions.items():
if isinstance(param_schema, dict) and param_name in default_params:
param_schema["default"] = default_params[param_name]
return {
"workflow": workflow_name,
"parameters": param_definitions,
"default_parameters": default_params,
"required_parameters": [
name for name, schema in param_definitions.items()
if isinstance(schema, dict) and schema.get("required", False)
]
}
+11
View File
@@ -0,0 +1,11 @@
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
+770
View File
@@ -0,0 +1,770 @@
"""
Prefect Manager - Core orchestration for workflow deployment and execution
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import logging
import os
import platform
import re
from pathlib import Path
from typing import Dict, Optional, Any
from prefect import get_client
from prefect.docker import DockerImage
from prefect.client.schemas import FlowRun
from src.core.workflow_discovery import WorkflowDiscovery, WorkflowInfo
logger = logging.getLogger(__name__)
def get_registry_url(context: str = "default") -> str:
"""
Get the container registry URL to use for a given operation context.
Goals:
- Work reliably across Linux and macOS Docker Desktop
- Prefer in-network service discovery when running inside containers
- Allow full override via env vars from docker-compose
Env overrides:
- FUZZFORGE_REGISTRY_PUSH_URL: used for image builds/pushes
- FUZZFORGE_REGISTRY_PULL_URL: used for workers to pull images
"""
# Normalize context
ctx = (context or "default").lower()
# Always honor explicit overrides first
if ctx in ("push", "build"):
push_url = os.getenv("FUZZFORGE_REGISTRY_PUSH_URL")
if push_url:
logger.debug("Using FUZZFORGE_REGISTRY_PUSH_URL: %s", push_url)
return push_url
# Default to host-published registry for Docker daemon operations
return "localhost:5001"
if ctx == "pull":
pull_url = os.getenv("FUZZFORGE_REGISTRY_PULL_URL")
if pull_url:
logger.debug("Using FUZZFORGE_REGISTRY_PULL_URL: %s", pull_url)
return pull_url
# Prefect worker pulls via host Docker daemon as well
return "localhost:5001"
# Default/fallback
return os.getenv("FUZZFORGE_REGISTRY_PULL_URL", os.getenv("FUZZFORGE_REGISTRY_PUSH_URL", "localhost:5001"))
def _compose_project_name(default: str = "fuzzforge_alpha") -> str:
"""Return the docker-compose project name used for network/volume naming.
Honors COMPOSE_PROJECT_NAME if present; falls back to a sensible default.
"""
return os.getenv("COMPOSE_PROJECT_NAME", default)
class PrefectManager:
"""
Manages Prefect deployments and flow runs for discovered workflows.
This class handles:
- Workflow discovery and registration
- Docker image building through Prefect
- Deployment creation and management
- Flow run submission with volume mounting
- Findings retrieval from completed runs
"""
def __init__(self, workflows_dir: Path = None):
"""
Initialize the Prefect manager.
Args:
workflows_dir: Path to the workflows directory (default: toolbox/workflows)
"""
if workflows_dir is None:
workflows_dir = Path("toolbox/workflows")
self.discovery = WorkflowDiscovery(workflows_dir)
self.workflows: Dict[str, WorkflowInfo] = {}
self.deployments: Dict[str, str] = {} # workflow_name -> deployment_id
# Security: Define allowed and forbidden paths for host mounting
self.allowed_base_paths = [
"/tmp",
"/home",
"/Users", # macOS users
"/opt",
"/var/tmp",
"/workspace", # Common container workspace
"/app" # Container application directory (for test projects)
]
self.forbidden_paths = [
"/etc",
"/root",
"/var/run",
"/sys",
"/proc",
"/dev",
"/boot",
"/var/lib/docker", # Critical Docker data
"/var/log", # System logs
"/usr/bin", # System binaries
"/usr/sbin",
"/sbin",
"/bin"
]
@staticmethod
def _parse_memory_to_bytes(memory_str: str) -> int:
"""
Parse memory string (like '512Mi', '1Gi') to bytes.
Args:
memory_str: Memory string with unit suffix
Returns:
Memory in bytes
Raises:
ValueError: If format is invalid
"""
if not memory_str:
return 0
match = re.match(r'^(\d+(?:\.\d+)?)\s*([GMK]i?)$', memory_str.strip())
if not match:
raise ValueError(f"Invalid memory format: {memory_str}. Expected format like '512Mi', '1Gi'")
value, unit = match.groups()
value = float(value)
# Convert to bytes based on unit (binary units: Ki, Mi, Gi)
if unit in ['K', 'Ki']:
multiplier = 1024
elif unit in ['M', 'Mi']:
multiplier = 1024 * 1024
elif unit in ['G', 'Gi']:
multiplier = 1024 * 1024 * 1024
else:
raise ValueError(f"Unsupported memory unit: {unit}")
return int(value * multiplier)
@staticmethod
def _parse_cpu_to_millicores(cpu_str: str) -> int:
"""
Parse CPU string (like '500m', '1', '2.5') to millicores.
Args:
cpu_str: CPU string
Returns:
CPU in millicores (1 core = 1000 millicores)
Raises:
ValueError: If format is invalid
"""
if not cpu_str:
return 0
cpu_str = cpu_str.strip()
# Handle millicores format (e.g., '500m')
if cpu_str.endswith('m'):
try:
return int(cpu_str[:-1])
except ValueError:
raise ValueError(f"Invalid CPU format: {cpu_str}")
# Handle core format (e.g., '1', '2.5')
try:
cores = float(cpu_str)
return int(cores * 1000) # Convert to millicores
except ValueError:
raise ValueError(f"Invalid CPU format: {cpu_str}")
def _extract_resource_requirements(self, workflow_info: WorkflowInfo) -> Dict[str, str]:
"""
Extract resource requirements from workflow metadata.
Args:
workflow_info: Workflow information with metadata
Returns:
Dictionary with resource requirements in Docker format
"""
metadata = workflow_info.metadata
requirements = metadata.get("requirements", {})
resources = requirements.get("resources", {})
resource_config = {}
# Extract memory requirement
memory = resources.get("memory")
if memory:
try:
# Validate memory format and store original string for Docker
self._parse_memory_to_bytes(memory)
resource_config["memory"] = memory
except ValueError as e:
logger.warning(f"Invalid memory requirement in {workflow_info.name}: {e}")
# Extract CPU requirement
cpu = resources.get("cpu")
if cpu:
try:
# Validate CPU format and store original string for Docker
self._parse_cpu_to_millicores(cpu)
resource_config["cpus"] = cpu
except ValueError as e:
logger.warning(f"Invalid CPU requirement in {workflow_info.name}: {e}")
# Extract timeout
timeout = resources.get("timeout")
if timeout and isinstance(timeout, int):
resource_config["timeout"] = str(timeout)
return resource_config
async def initialize(self):
"""
Initialize the manager by discovering and deploying all workflows.
This method:
1. Discovers all valid workflows in the workflows directory
2. Validates their metadata
3. Deploys each workflow to Prefect with Docker images
"""
try:
# Discover workflows
self.workflows = await self.discovery.discover_workflows()
if not self.workflows:
logger.warning("No workflows discovered")
return
logger.info(f"Discovered {len(self.workflows)} workflows: {list(self.workflows.keys())}")
# Deploy each workflow
for name, info in self.workflows.items():
try:
await self._deploy_workflow(name, info)
except Exception as e:
logger.error(f"Failed to deploy workflow '{name}': {e}")
except Exception as e:
logger.error(f"Failed to initialize Prefect manager: {e}")
raise
async def _deploy_workflow(self, name: str, info: WorkflowInfo):
"""
Deploy a single workflow to Prefect with Docker image.
Args:
name: Workflow name
info: Workflow information including metadata and paths
"""
logger.info(f"Deploying workflow '{name}'...")
# Get the flow function from registry
flow_func = self.discovery.get_flow_function(name)
if not flow_func:
logger.error(
f"Failed to get flow function for '{name}' from registry. "
f"Ensure the workflow is properly registered in toolbox/workflows/registry.py"
)
return
# Use the mandatory Dockerfile with absolute paths for Docker Compose
# Get absolute paths for build context and dockerfile
toolbox_path = info.path.parent.parent.resolve()
dockerfile_abs_path = info.dockerfile.resolve()
# Calculate relative dockerfile path from toolbox context
try:
dockerfile_rel_path = dockerfile_abs_path.relative_to(toolbox_path)
except ValueError:
# If relative path fails, use the workflow-specific path
dockerfile_rel_path = Path("workflows") / name / "Dockerfile"
# Determine deployment strategy based on Dockerfile presence
base_image = "prefecthq/prefect:3-python3.11"
has_custom_dockerfile = info.has_docker and info.dockerfile.exists()
logger.info(f"=== DEPLOYMENT DEBUG for '{name}' ===")
logger.info(f"info.has_docker: {info.has_docker}")
logger.info(f"info.dockerfile: {info.dockerfile}")
logger.info(f"info.dockerfile.exists(): {info.dockerfile.exists()}")
logger.info(f"has_custom_dockerfile: {has_custom_dockerfile}")
logger.info(f"toolbox_path: {toolbox_path}")
logger.info(f"dockerfile_rel_path: {dockerfile_rel_path}")
if has_custom_dockerfile:
logger.info(f"Workflow '{name}' has custom Dockerfile - building custom image")
# Decide whether to use registry or keep images local to host engine
import os
# Default to using the local registry; set FUZZFORGE_USE_REGISTRY=false to bypass (not recommended)
use_registry = os.getenv("FUZZFORGE_USE_REGISTRY", "true").lower() == "true"
if use_registry:
registry_url = get_registry_url(context="push")
image_spec = DockerImage(
name=f"{registry_url}/fuzzforge/{name}",
tag="latest",
dockerfile=str(dockerfile_rel_path),
context=str(toolbox_path)
)
deploy_image = f"{registry_url}/fuzzforge/{name}:latest"
build_custom = True
push_custom = True
logger.info(f"Using registry: {registry_url} for '{name}'")
else:
# Single-host mode: build into host engine cache; no push required
image_spec = DockerImage(
name=f"fuzzforge/{name}",
tag="latest",
dockerfile=str(dockerfile_rel_path),
context=str(toolbox_path)
)
deploy_image = f"fuzzforge/{name}:latest"
build_custom = True
push_custom = False
logger.info("Using single-host image (no registry push): %s", deploy_image)
else:
logger.info(f"Workflow '{name}' using base image - no custom dependencies needed")
deploy_image = base_image
build_custom = False
push_custom = False
# Pre-validate registry connectivity when pushing
if push_custom:
try:
from .setup import validate_registry_connectivity
await validate_registry_connectivity(registry_url)
logger.info(f"Registry connectivity validated for {registry_url}")
except Exception as e:
logger.error(f"Registry connectivity validation failed for {registry_url}: {e}")
raise RuntimeError(f"Cannot deploy workflow '{name}': Registry {registry_url} is not accessible. {e}")
# Deploy the workflow
try:
# Ensure any previous deployment is removed so job variables are updated
try:
async with get_client() as client:
existing = await client.read_deployment_by_name(
f"{name}/{name}-deployment"
)
if existing:
logger.info(f"Removing existing deployment for '{name}' to refresh settings...")
await client.delete_deployment(existing.id)
except Exception:
# If not found or deletion fails, continue with deployment
pass
# Extract resource requirements from metadata
workflow_resource_requirements = self._extract_resource_requirements(info)
logger.info(f"Workflow '{name}' resource requirements: {workflow_resource_requirements}")
# Build job variables with resource requirements
job_variables = {
"image": deploy_image, # Use the worker-accessible registry name
"volumes": [], # Populated at run submission with toolbox mount
"env": {
"PYTHONPATH": "/opt/prefect/toolbox:/opt/prefect/toolbox/workflows",
"WORKFLOW_NAME": name
}
}
# Add resource requirements to job variables if present
if workflow_resource_requirements:
job_variables["resources"] = workflow_resource_requirements
# Prepare deployment parameters
deploy_params = {
"name": f"{name}-deployment",
"work_pool_name": "docker-pool",
"image": image_spec if has_custom_dockerfile else deploy_image,
"push": push_custom,
"build": build_custom,
"job_variables": job_variables
}
deployment = await flow_func.deploy(**deploy_params)
self.deployments[name] = str(deployment.id) if hasattr(deployment, 'id') else name
logger.info(f"Successfully deployed workflow '{name}'")
except Exception as e:
# Enhanced error reporting with more context
import traceback
logger.error(f"Failed to deploy workflow '{name}': {e}")
logger.error(f"Deployment traceback: {traceback.format_exc()}")
# Try to capture Docker-specific context
error_context = {
"workflow_name": name,
"has_dockerfile": has_custom_dockerfile,
"image_name": deploy_image if 'deploy_image' in locals() else "unknown",
"registry_url": registry_url if 'registry_url' in locals() else "unknown",
"error_type": type(e).__name__,
"error_message": str(e)
}
# Check for specific error patterns with detailed categorization
error_msg_lower = str(e).lower()
if "registry" in error_msg_lower and ("no such host" in error_msg_lower or "connection" in error_msg_lower):
error_context["category"] = "registry_connectivity_error"
error_context["solution"] = f"Cannot reach registry at {error_context['registry_url']}. Check Docker network and registry service."
elif "docker" in error_msg_lower:
error_context["category"] = "docker_error"
if "build" in error_msg_lower:
error_context["subcategory"] = "image_build_failed"
error_context["solution"] = "Check Dockerfile syntax and dependencies."
elif "pull" in error_msg_lower:
error_context["subcategory"] = "image_pull_failed"
error_context["solution"] = "Check if image exists in registry and network connectivity."
elif "push" in error_msg_lower:
error_context["subcategory"] = "image_push_failed"
error_context["solution"] = f"Check registry connectivity and push permissions to {error_context['registry_url']}."
elif "registry" in error_msg_lower:
error_context["category"] = "registry_error"
error_context["solution"] = "Check registry configuration and accessibility."
elif "prefect" in error_msg_lower:
error_context["category"] = "prefect_error"
error_context["solution"] = "Check Prefect server connectivity and deployment configuration."
else:
error_context["category"] = "unknown_deployment_error"
error_context["solution"] = "Check logs for more specific error details."
logger.error(f"Deployment error context: {error_context}")
# Raise enhanced exception with context
enhanced_error = Exception(f"Deployment failed for workflow '{name}': {str(e)} | Context: {error_context}")
enhanced_error.original_error = e
enhanced_error.context = error_context
raise enhanced_error
async def submit_workflow(
self,
workflow_name: str,
target_path: str,
volume_mode: str = "ro",
parameters: Dict[str, Any] = None,
resource_limits: Dict[str, str] = None,
additional_volumes: list = None,
timeout: int = None
) -> FlowRun:
"""
Submit a workflow for execution with volume mounting.
Args:
workflow_name: Name of the workflow to execute
target_path: Host path to mount as volume
volume_mode: Volume mount mode ("ro" for read-only, "rw" for read-write)
parameters: Workflow-specific parameters
resource_limits: CPU/memory limits for container
additional_volumes: List of additional volume mounts
timeout: Timeout in seconds
Returns:
FlowRun object with run information
Raises:
ValueError: If workflow not found or volume mode not supported
"""
if workflow_name not in self.workflows:
raise ValueError(f"Unknown workflow: {workflow_name}")
# Validate volume mode
workflow_info = self.workflows[workflow_name]
supported_modes = workflow_info.metadata.get("supported_volume_modes", ["ro", "rw"])
if volume_mode not in supported_modes:
raise ValueError(
f"Workflow '{workflow_name}' doesn't support volume mode '{volume_mode}'. "
f"Supported modes: {supported_modes}"
)
# Validate target path with security checks
self._validate_target_path(target_path)
# Validate additional volumes if provided
if additional_volumes:
for volume in additional_volumes:
self._validate_target_path(volume.host_path)
async with get_client() as client:
# Get the deployment, auto-redeploy once if missing
try:
deployment = await client.read_deployment_by_name(
f"{workflow_name}/{workflow_name}-deployment"
)
except Exception as e:
import traceback
logger.error(f"Failed to find deployment for workflow '{workflow_name}': {e}")
logger.error(f"Deployment lookup traceback: {traceback.format_exc()}")
# Attempt a one-time auto-deploy to recover from startup races
try:
logger.info(f"Auto-deploying missing workflow '{workflow_name}' and retrying...")
await self._deploy_workflow(workflow_name, workflow_info)
deployment = await client.read_deployment_by_name(
f"{workflow_name}/{workflow_name}-deployment"
)
except Exception as redeploy_exc:
# Enhanced error with context
error_context = {
"workflow_name": workflow_name,
"error_type": type(e).__name__,
"error_message": str(e),
"redeploy_error": str(redeploy_exc),
"available_deployments": list(self.deployments.keys()),
}
enhanced_error = ValueError(
f"Deployment not found and redeploy failed for workflow '{workflow_name}': {e} | Context: {error_context}"
)
enhanced_error.context = error_context
raise enhanced_error
# Determine the Docker Compose network name and volume names
# Docker Compose creates networks with pattern: {project_name}_default
import os
compose_project = _compose_project_name('fuzzforge_alpha')
docker_network = f"{compose_project}_default"
# Build volume mounts
# Add toolbox volume mount for workflow code access
backend_toolbox_path = "/app/toolbox" # Path in backend container
# Use dynamic volume names based on Docker Compose project name
prefect_storage_volume = f"{compose_project}_prefect_storage"
toolbox_code_volume = f"{compose_project}_toolbox_code"
volumes = [
f"{target_path}:/workspace:{volume_mode}",
f"{prefect_storage_volume}:/prefect-storage", # Shared storage for results
f"{toolbox_code_volume}:/opt/prefect/toolbox:ro" # Mount workflow code
]
# Add additional volumes if provided
if additional_volumes:
for volume in additional_volumes:
volume_spec = f"{volume.host_path}:{volume.container_path}:{volume.mode}"
volumes.append(volume_spec)
# Build environment variables
env_vars = {
"PREFECT_API_URL": "http://prefect-server:4200/api", # Use internal network hostname
"PREFECT_LOGGING_LEVEL": "INFO",
"PREFECT_LOCAL_STORAGE_PATH": "/prefect-storage", # Use shared storage
"PREFECT_RESULTS_PERSIST_BY_DEFAULT": "true", # Enable result persistence
"PREFECT_DEFAULT_RESULT_STORAGE_BLOCK": "local-file-system/fuzzforge-results", # Use our storage block
"WORKSPACE_PATH": "/workspace",
"VOLUME_MODE": volume_mode,
"WORKFLOW_NAME": workflow_name
}
# Add additional volume paths to environment for easy access
if additional_volumes:
for i, volume in enumerate(additional_volumes):
env_vars[f"ADDITIONAL_VOLUME_{i}_PATH"] = volume.container_path
# Determine which image to use based on workflow configuration
workflow_info = self.workflows[workflow_name]
has_custom_dockerfile = workflow_info.has_docker and workflow_info.dockerfile.exists()
# Use pull context for worker to pull from registry
registry_url = get_registry_url(context="pull")
workflow_image = f"{registry_url}/fuzzforge/{workflow_name}:latest" if has_custom_dockerfile else "prefecthq/prefect:3-python3.11"
logger.debug(f"Worker will pull image: {workflow_image} (Registry: {registry_url})")
# Configure job variables with volume mounting and network access
job_variables = {
# Use custom image if available, otherwise base Prefect image
"image": workflow_image,
"volumes": volumes,
"networks": [docker_network], # Connect to Docker Compose network
"env": {
**env_vars,
"PYTHONPATH": "/opt/prefect/toolbox:/opt/prefect/toolbox/workflows",
"WORKFLOW_NAME": workflow_name
}
}
# Apply resource requirements from workflow metadata and user overrides
workflow_resource_requirements = self._extract_resource_requirements(workflow_info)
final_resource_config = {}
# Start with workflow requirements as base
if workflow_resource_requirements:
final_resource_config.update(workflow_resource_requirements)
# Apply user-provided resource limits (overrides workflow defaults)
if resource_limits:
user_resource_config = {}
if resource_limits.get("cpu_limit"):
user_resource_config["cpus"] = resource_limits["cpu_limit"]
if resource_limits.get("memory_limit"):
user_resource_config["memory"] = resource_limits["memory_limit"]
# Note: cpu_request and memory_request are not directly supported by Docker
# but could be used for Kubernetes in the future
# User overrides take precedence
final_resource_config.update(user_resource_config)
# Apply final resource configuration
if final_resource_config:
job_variables["resources"] = final_resource_config
logger.info(f"Applied resource limits: {final_resource_config}")
# Merge parameters with defaults from metadata
default_params = workflow_info.metadata.get("default_parameters", {})
final_params = {**default_params, **(parameters or {})}
# Set flow parameters that match the flow signature
final_params["target_path"] = "/workspace" # Container path where volume is mounted
final_params["volume_mode"] = volume_mode
# Create and submit the flow run
# Pass job_variables to ensure network, volumes, and environment are configured
logger.info(f"Submitting flow with job_variables: {job_variables}")
logger.info(f"Submitting flow with parameters: {final_params}")
# Prepare flow run creation parameters
flow_run_params = {
"deployment_id": deployment.id,
"parameters": final_params,
"job_variables": job_variables
}
# Note: Timeout is handled through workflow-level configuration
# Additional timeout configuration can be added to deployment metadata if needed
flow_run = await client.create_flow_run_from_deployment(**flow_run_params)
logger.info(
f"Submitted workflow '{workflow_name}' with run_id: {flow_run.id}, "
f"target: {target_path}, mode: {volume_mode}"
)
return flow_run
async def get_flow_run_findings(self, run_id: str) -> Dict[str, Any]:
"""
Retrieve findings from a completed flow run.
Args:
run_id: The flow run ID
Returns:
Dictionary containing SARIF-formatted findings
Raises:
ValueError: If run not completed or not found
"""
async with get_client() as client:
flow_run = await client.read_flow_run(run_id)
if not flow_run.state.is_completed():
raise ValueError(
f"Flow run {run_id} not completed. Current status: {flow_run.state.name}"
)
# Get the findings from the flow run result
try:
findings = await flow_run.state.result()
return findings
except Exception as e:
logger.error(f"Failed to retrieve findings for run {run_id}: {e}")
raise ValueError(f"Failed to retrieve findings: {e}")
async def get_flow_run_status(self, run_id: str) -> Dict[str, Any]:
"""
Get the current status of a flow run.
Args:
run_id: The flow run ID
Returns:
Dictionary with status information
"""
async with get_client() as client:
flow_run = await client.read_flow_run(run_id)
return {
"run_id": str(flow_run.id),
"workflow": flow_run.deployment_id,
"status": flow_run.state.name,
"is_completed": flow_run.state.is_completed(),
"is_failed": flow_run.state.is_failed(),
"is_running": flow_run.state.is_running(),
"created_at": flow_run.created,
"updated_at": flow_run.updated
}
def _validate_target_path(self, target_path: str) -> None:
"""
Validate target path for security before mounting as volume.
Args:
target_path: Host path to validate
Raises:
ValueError: If path is not allowed for security reasons
"""
target = Path(target_path)
# Path must be absolute
if not target.is_absolute():
raise ValueError(f"Target path must be absolute: {target_path}")
# Resolve path to handle symlinks and relative components
try:
resolved_path = target.resolve()
except (OSError, RuntimeError) as e:
raise ValueError(f"Cannot resolve target path: {target_path} - {e}")
resolved_str = str(resolved_path)
# Check against forbidden paths first (more restrictive)
for forbidden in self.forbidden_paths:
if resolved_str.startswith(forbidden):
raise ValueError(
f"Access denied: Path '{target_path}' resolves to forbidden directory '{forbidden}'. "
f"This path contains sensitive system files and cannot be mounted."
)
# Check if path starts with any allowed base path
path_allowed = False
for allowed in self.allowed_base_paths:
if resolved_str.startswith(allowed):
path_allowed = True
break
if not path_allowed:
allowed_list = ", ".join(self.allowed_base_paths)
raise ValueError(
f"Access denied: Path '{target_path}' is not in allowed directories. "
f"Allowed base paths: {allowed_list}"
)
# Additional security checks
if resolved_str == "/":
raise ValueError("Cannot mount root filesystem")
# Warn if path doesn't exist (but don't block - it might be created later)
if not resolved_path.exists():
logger.warning(f"Target path does not exist: {target_path}")
logger.info(f"Path validation passed for: {target_path} -> {resolved_str}")
+402
View File
@@ -0,0 +1,402 @@
"""
Setup utilities for Prefect infrastructure
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import logging
from prefect import get_client
from prefect.client.schemas.actions import WorkPoolCreate
from prefect.client.schemas.objects import WorkPool
from .prefect_manager import get_registry_url
logger = logging.getLogger(__name__)
async def setup_docker_pool():
"""
Create or update the Docker work pool for container execution.
This work pool is configured to:
- Connect to the local Docker daemon
- Support volume mounting at runtime
- Clean up containers after execution
- Use bridge networking by default
"""
import os
async with get_client() as client:
pool_name = "docker-pool"
# Add force recreation flag for debugging fresh install issues
force_recreate = os.getenv('FORCE_RECREATE_WORK_POOL', 'false').lower() == 'true'
debug_setup = os.getenv('DEBUG_WORK_POOL_SETUP', 'false').lower() == 'true'
if force_recreate:
logger.warning(f"FORCE_RECREATE_WORK_POOL=true - Will recreate work pool regardless of existing configuration")
if debug_setup:
logger.warning(f"DEBUG_WORK_POOL_SETUP=true - Enhanced logging enabled")
# Temporarily set logging level to DEBUG for this function
original_level = logger.level
logger.setLevel(logging.DEBUG)
try:
# Check if pool already exists and supports custom images
existing_pools = await client.read_work_pools()
existing_pool = None
for pool in existing_pools:
if pool.name == pool_name:
existing_pool = pool
break
if existing_pool and not force_recreate:
logger.info(f"Found existing work pool '{pool_name}' - validating configuration...")
# Check if the existing pool has the correct configuration
base_template = existing_pool.base_job_template or {}
logger.debug(f"Base template keys: {list(base_template.keys())}")
job_config = base_template.get("job_configuration", {})
logger.debug(f"Job config keys: {list(job_config.keys())}")
image_config = job_config.get("image", "")
has_image_variable = "{{ image }}" in str(image_config)
logger.debug(f"Image config: '{image_config}' -> has_image_variable: {has_image_variable}")
# Check if volume defaults include toolbox mount
variables = base_template.get("variables", {})
properties = variables.get("properties", {})
volume_config = properties.get("volumes", {})
volume_defaults = volume_config.get("default", [])
has_toolbox_volume = any("toolbox_code" in str(vol) for vol in volume_defaults) if volume_defaults else False
logger.debug(f"Volume defaults: {volume_defaults}")
logger.debug(f"Has toolbox volume: {has_toolbox_volume}")
# Check if environment defaults include required settings
env_config = properties.get("env", {})
env_defaults = env_config.get("default", {})
has_api_url = "PREFECT_API_URL" in env_defaults
has_storage_path = "PREFECT_LOCAL_STORAGE_PATH" in env_defaults
has_results_persist = "PREFECT_RESULTS_PERSIST_BY_DEFAULT" in env_defaults
has_required_env = has_api_url and has_storage_path and has_results_persist
logger.debug(f"Environment defaults: {env_defaults}")
logger.debug(f"Has API URL: {has_api_url}, Has storage path: {has_storage_path}, Has results persist: {has_results_persist}")
logger.debug(f"Has required env: {has_required_env}")
# Log the full validation result
logger.info(f"Work pool validation - Image: {has_image_variable}, Toolbox: {has_toolbox_volume}, Environment: {has_required_env}")
if has_image_variable and has_toolbox_volume and has_required_env:
logger.info(f"Docker work pool '{pool_name}' already exists with correct configuration")
return
else:
reasons = []
if not has_image_variable:
reasons.append("missing image template")
if not has_toolbox_volume:
reasons.append("missing toolbox volume mount")
if not has_required_env:
if not has_api_url:
reasons.append("missing PREFECT_API_URL")
if not has_storage_path:
reasons.append("missing PREFECT_LOCAL_STORAGE_PATH")
if not has_results_persist:
reasons.append("missing PREFECT_RESULTS_PERSIST_BY_DEFAULT")
logger.warning(f"Docker work pool '{pool_name}' exists but lacks: {', '.join(reasons)}. Recreating...")
# Delete the old pool and recreate it
try:
await client.delete_work_pool(pool_name)
logger.info(f"Deleted old work pool '{pool_name}'")
except Exception as e:
logger.warning(f"Failed to delete old work pool: {e}")
elif force_recreate and existing_pool:
logger.warning(f"Force recreation enabled - deleting existing work pool '{pool_name}'")
try:
await client.delete_work_pool(pool_name)
logger.info(f"Deleted existing work pool for force recreation")
except Exception as e:
logger.warning(f"Failed to delete work pool for force recreation: {e}")
logger.info(f"Creating Docker work pool '{pool_name}' with custom image support...")
# Create the work pool with proper Docker configuration
work_pool = WorkPoolCreate(
name=pool_name,
type="docker",
description="Docker work pool for FuzzForge workflows with custom image support",
base_job_template={
"job_configuration": {
"image": "{{ image }}", # Template variable for custom images
"volumes": "{{ volumes }}", # List of volume mounts
"env": "{{ env }}", # Environment variables
"networks": "{{ networks }}", # Docker networks
"stream_output": True,
"auto_remove": True,
"privileged": False,
"network_mode": None, # Use networks instead
"labels": {},
"command": None # Let the image's CMD/ENTRYPOINT run
},
"variables": {
"type": "object",
"properties": {
"image": {
"type": "string",
"title": "Docker Image",
"default": "prefecthq/prefect:3-python3.11",
"description": "Docker image for the flow run"
},
"volumes": {
"type": "array",
"title": "Volume Mounts",
"default": [
f"{get_actual_compose_project_name()}_prefect_storage:/prefect-storage",
f"{get_actual_compose_project_name()}_toolbox_code:/opt/prefect/toolbox:ro"
],
"description": "Volume mounts in format 'host:container:mode'",
"items": {
"type": "string"
}
},
"networks": {
"type": "array",
"title": "Docker Networks",
"default": [f"{get_actual_compose_project_name()}_default"],
"description": "Docker networks to connect container to",
"items": {
"type": "string"
}
},
"env": {
"type": "object",
"title": "Environment Variables",
"default": {
"PREFECT_API_URL": "http://prefect-server:4200/api",
"PREFECT_LOCAL_STORAGE_PATH": "/prefect-storage",
"PREFECT_RESULTS_PERSIST_BY_DEFAULT": "true"
},
"description": "Environment variables for the container",
"additionalProperties": {
"type": "string"
}
}
}
}
}
)
await client.create_work_pool(work_pool)
logger.info(f"Created Docker work pool '{pool_name}'")
except Exception as e:
logger.error(f"Failed to setup Docker work pool: {e}")
raise
finally:
# Restore original logging level if debug mode was enabled
if debug_setup and 'original_level' in locals():
logger.setLevel(original_level)
def get_actual_compose_project_name():
"""
Return the hardcoded compose project name for FuzzForge.
Always returns 'fuzzforge_alpha' as per system requirements.
"""
logger.info("Using hardcoded compose project name: fuzzforge_alpha")
return "fuzzforge_alpha"
async def setup_result_storage():
"""
Create or update Prefect result storage block for findings persistence.
This sets up a LocalFileSystem storage block pointing to the shared
/prefect-storage volume for result persistence.
"""
from prefect.filesystems import LocalFileSystem
storage_name = "fuzzforge-results"
try:
# Create the storage block, overwrite if it exists
logger.info(f"Setting up storage block '{storage_name}'...")
storage = LocalFileSystem(basepath="/prefect-storage")
block_doc_id = await storage.save(name=storage_name, overwrite=True)
logger.info(f"Storage block '{storage_name}' configured successfully")
return str(block_doc_id)
except Exception as e:
logger.error(f"Failed to setup result storage: {e}")
# Don't raise the exception - continue without storage block
logger.warning("Continuing without result storage block - findings may not persist")
return None
async def validate_docker_connection():
"""
Validate that Docker is accessible and running.
Note: In containerized deployments with Docker socket proxy,
the backend doesn't need direct Docker access.
Raises:
RuntimeError: If Docker is not accessible
"""
import os
# Skip Docker validation if running in container without socket access
if os.path.exists("/.dockerenv") and not os.path.exists("/var/run/docker.sock"):
logger.info("Running in container without Docker socket - skipping Docker validation")
return
try:
import docker
client = docker.from_env()
client.ping()
logger.info("Docker connection validated")
except Exception as e:
logger.error(f"Docker is not accessible: {e}")
raise RuntimeError(
"Docker is not running or not accessible. "
"Please ensure Docker is installed and running."
)
async def validate_registry_connectivity(registry_url: str = None):
"""
Validate that the Docker registry is accessible.
Args:
registry_url: URL of the Docker registry to validate (auto-detected if None)
Raises:
RuntimeError: If registry is not accessible
"""
# Resolve a reachable test URL from within this process
if registry_url is None:
# If not specified, prefer internal service name in containers, host port on host
import os
if os.path.exists('/.dockerenv'):
registry_url = "registry:5000"
else:
registry_url = "localhost:5001"
# If we're running inside a container and asked to probe localhost:PORT,
# the probe would hit the container, not the host. Use host.docker.internal instead.
import os
try:
host_part, port_part = registry_url.split(":", 1)
except ValueError:
host_part, port_part = registry_url, "80"
if os.path.exists('/.dockerenv') and host_part in ("localhost", "127.0.0.1"):
test_host = "host.docker.internal"
else:
test_host = host_part
test_url = f"http://{test_host}:{port_part}/v2/"
import aiohttp
import asyncio
logger.info(f"Validating registry connectivity to {registry_url}...")
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
async with session.get(test_url) as response:
if response.status == 200:
logger.info(f"Registry at {registry_url} is accessible (tested via {test_host})")
return
else:
raise RuntimeError(f"Registry returned status {response.status}")
except asyncio.TimeoutError:
raise RuntimeError(f"Registry at {registry_url} is not responding (timeout)")
except aiohttp.ClientError as e:
raise RuntimeError(f"Registry at {registry_url} is not accessible: {e}")
except Exception as e:
raise RuntimeError(f"Failed to validate registry connectivity: {e}")
async def validate_docker_network(network_name: str):
"""
Validate that the specified Docker network exists.
Args:
network_name: Name of the Docker network to validate
Raises:
RuntimeError: If network doesn't exist
"""
import os
# Skip network validation if running in container without Docker socket
if os.path.exists("/.dockerenv") and not os.path.exists("/var/run/docker.sock"):
logger.info("Running in container without Docker socket - skipping network validation")
return
try:
import docker
client = docker.from_env()
# List all networks
networks = client.networks.list(names=[network_name])
if not networks:
# Try to find networks with similar names
all_networks = client.networks.list()
similar_networks = [n.name for n in all_networks if "fuzzforge" in n.name.lower()]
error_msg = f"Docker network '{network_name}' not found."
if similar_networks:
error_msg += f" Available networks: {similar_networks}"
else:
error_msg += " Please ensure Docker Compose is running."
raise RuntimeError(error_msg)
logger.info(f"Docker network '{network_name}' validated")
except Exception as e:
if isinstance(e, RuntimeError):
raise
logger.error(f"Network validation failed: {e}")
raise RuntimeError(f"Failed to validate Docker network: {e}")
async def validate_infrastructure():
"""
Validate all required infrastructure components.
This should be called during startup to ensure everything is ready.
"""
logger.info("Validating infrastructure...")
# Validate Docker connection
await validate_docker_connection()
# Validate registry connectivity for custom image building
await validate_registry_connectivity()
# Validate network (check for default network pattern)
import os
compose_project = os.getenv('COMPOSE_PROJECT_NAME', 'fuzzforge_alpha')
docker_network = f"{compose_project}_default"
try:
await validate_docker_network(docker_network)
except RuntimeError as e:
logger.warning(f"Network validation failed: {e}")
logger.warning("Workflows may not be able to connect to Prefect services")
logger.info("Infrastructure validation completed")
+459
View File
@@ -0,0 +1,459 @@
"""
Workflow Discovery - Registry-based discovery and loading of workflows
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import logging
import yaml
from pathlib import Path
from typing import Dict, Optional, Any, Callable
from pydantic import BaseModel, Field, ConfigDict
logger = logging.getLogger(__name__)
class WorkflowInfo(BaseModel):
"""Information about a discovered workflow"""
name: str = Field(..., description="Workflow name")
path: Path = Field(..., description="Path to workflow directory")
workflow_file: Path = Field(..., description="Path to workflow.py file")
dockerfile: Path = Field(..., description="Path to Dockerfile")
has_docker: bool = Field(..., description="Whether workflow has custom Dockerfile")
metadata: Dict[str, Any] = Field(..., description="Workflow metadata from YAML")
flow_function_name: str = Field(default="main_flow", description="Name of the flow function")
model_config = ConfigDict(arbitrary_types_allowed=True)
class WorkflowDiscovery:
"""
Discovers workflows from the filesystem and validates them against the registry.
This system:
1. Scans for workflows with metadata.yaml files
2. Cross-references them with the manual registry
3. Provides registry-based flow functions for deployment
Workflows must have:
- workflow.py: Contains the Prefect flow
- metadata.yaml: Mandatory metadata file
- Entry in toolbox/workflows/registry.py: Manual registration
- Dockerfile (optional): Custom container definition
- requirements.txt (optional): Python dependencies
"""
def __init__(self, workflows_dir: Path):
"""
Initialize workflow discovery.
Args:
workflows_dir: Path to the workflows directory
"""
self.workflows_dir = workflows_dir
if not self.workflows_dir.exists():
self.workflows_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Created workflows directory: {self.workflows_dir}")
# Import registry - this validates it on import
try:
from toolbox.workflows.registry import WORKFLOW_REGISTRY, list_registered_workflows
self.registry = WORKFLOW_REGISTRY
logger.info(f"Loaded workflow registry with {len(self.registry)} registered workflows")
except ImportError as e:
logger.error(f"Failed to import workflow registry: {e}")
self.registry = {}
except Exception as e:
logger.error(f"Registry validation failed: {e}")
self.registry = {}
# Cache for discovered workflows
self._workflow_cache: Optional[Dict[str, WorkflowInfo]] = None
self._cache_timestamp: Optional[float] = None
self._cache_ttl = 60.0 # Cache TTL in seconds
async def discover_workflows(self) -> Dict[str, WorkflowInfo]:
"""
Discover workflows by cross-referencing filesystem with registry.
Uses caching to avoid frequent filesystem scans.
Returns:
Dictionary mapping workflow names to their information
"""
# Check cache validity
import time
current_time = time.time()
if (self._workflow_cache is not None and
self._cache_timestamp is not None and
(current_time - self._cache_timestamp) < self._cache_ttl):
# Return cached results
logger.debug(f"Returning cached workflow discovery ({len(self._workflow_cache)} workflows)")
return self._workflow_cache
workflows = {}
discovered_dirs = set()
registry_names = set(self.registry.keys())
if not self.workflows_dir.exists():
logger.warning(f"Workflows directory does not exist: {self.workflows_dir}")
return workflows
# Recursively scan all directories and subdirectories
await self._scan_directory_recursive(self.workflows_dir, workflows, discovered_dirs)
# Check for registry entries without corresponding directories
missing_dirs = registry_names - discovered_dirs
if missing_dirs:
logger.warning(
f"Registry contains workflows without filesystem directories: {missing_dirs}. "
f"These workflows cannot be deployed."
)
logger.info(
f"Discovery complete: {len(workflows)} workflows ready for deployment, "
f"{len(missing_dirs)} registry entries missing directories, "
f"{len(discovered_dirs - registry_names)} filesystem workflows not registered"
)
# Update cache
self._workflow_cache = workflows
self._cache_timestamp = current_time
return workflows
async def _scan_directory_recursive(self, directory: Path, workflows: Dict[str, WorkflowInfo], discovered_dirs: set):
"""
Recursively scan directory for workflows.
Args:
directory: Directory to scan
workflows: Dictionary to populate with discovered workflows
discovered_dirs: Set to track discovered workflow names
"""
for item in directory.iterdir():
if not item.is_dir():
continue
if item.name.startswith('_') or item.name.startswith('.'):
continue # Skip hidden or private directories
# Check if this directory contains workflow files (workflow.py and metadata.yaml)
workflow_file = item / "workflow.py"
metadata_file = item / "metadata.yaml"
if workflow_file.exists() and metadata_file.exists():
# This is a workflow directory
workflow_name = item.name
discovered_dirs.add(workflow_name)
# Only process workflows that are in the registry
if workflow_name not in self.registry:
logger.warning(
f"Workflow '{workflow_name}' found in filesystem but not in registry. "
f"Add it to toolbox/workflows/registry.py to enable deployment."
)
continue
try:
workflow_info = await self._load_workflow(item)
if workflow_info:
workflows[workflow_info.name] = workflow_info
logger.info(f"Discovered and registered workflow: {workflow_info.name}")
except Exception as e:
logger.error(f"Failed to load workflow from {item}: {e}")
else:
# This is a category directory, recurse into it
await self._scan_directory_recursive(item, workflows, discovered_dirs)
async def _load_workflow(self, workflow_dir: Path) -> Optional[WorkflowInfo]:
"""
Load and validate a single workflow.
Args:
workflow_dir: Path to the workflow directory
Returns:
WorkflowInfo if valid, None otherwise
"""
workflow_name = workflow_dir.name
# Check for mandatory files
workflow_file = workflow_dir / "workflow.py"
metadata_file = workflow_dir / "metadata.yaml"
if not workflow_file.exists():
logger.warning(f"Workflow {workflow_name} missing workflow.py")
return None
if not metadata_file.exists():
logger.error(f"Workflow {workflow_name} missing mandatory metadata.yaml")
return None
# Load and validate metadata
try:
metadata = self._load_metadata(metadata_file)
if not self._validate_metadata(metadata, workflow_name):
return None
except Exception as e:
logger.error(f"Failed to load metadata for {workflow_name}: {e}")
return None
# Check for mandatory Dockerfile
dockerfile = workflow_dir / "Dockerfile"
if not dockerfile.exists():
logger.error(f"Workflow {workflow_name} missing mandatory Dockerfile")
return None
has_docker = True # Always True since Dockerfile is mandatory
# Get flow function name from metadata or use default
flow_function_name = metadata.get("flow_function", "main_flow")
return WorkflowInfo(
name=workflow_name,
path=workflow_dir,
workflow_file=workflow_file,
dockerfile=dockerfile,
has_docker=has_docker,
metadata=metadata,
flow_function_name=flow_function_name
)
def _load_metadata(self, metadata_file: Path) -> Dict[str, Any]:
"""
Load metadata from YAML file.
Args:
metadata_file: Path to metadata.yaml
Returns:
Dictionary containing metadata
"""
with open(metadata_file, 'r') as f:
metadata = yaml.safe_load(f)
if metadata is None:
raise ValueError("Empty metadata file")
return metadata
def _validate_metadata(self, metadata: Dict[str, Any], workflow_name: str) -> bool:
"""
Validate that metadata contains all required fields.
Args:
metadata: Metadata dictionary
workflow_name: Name of the workflow for logging
Returns:
True if valid, False otherwise
"""
required_fields = ["name", "version", "description", "author", "category", "parameters", "requirements"]
missing_fields = []
for field in required_fields:
if field not in metadata:
missing_fields.append(field)
if missing_fields:
logger.error(
f"Workflow {workflow_name} metadata missing required fields: {missing_fields}"
)
return False
# Validate version format (semantic versioning)
version = metadata.get("version", "")
if not self._is_valid_version(version):
logger.error(f"Workflow {workflow_name} has invalid version format: {version}")
return False
# Validate parameters structure
parameters = metadata.get("parameters", {})
if not isinstance(parameters, dict):
logger.error(f"Workflow {workflow_name} parameters must be a dictionary")
return False
return True
def _is_valid_version(self, version: str) -> bool:
"""
Check if version follows semantic versioning (x.y.z).
Args:
version: Version string
Returns:
True if valid semantic version
"""
try:
parts = version.split('.')
if len(parts) != 3:
return False
for part in parts:
int(part) # Check if each part is a number
return True
except (ValueError, AttributeError):
return False
def invalidate_cache(self) -> None:
"""
Invalidate the workflow discovery cache.
Useful when workflows are added or modified.
"""
self._workflow_cache = None
self._cache_timestamp = None
logger.debug("Workflow discovery cache invalidated")
def get_flow_function(self, workflow_name: str) -> Optional[Callable]:
"""
Get the flow function from the registry.
Args:
workflow_name: Name of the workflow
Returns:
The flow function if found in registry, None otherwise
"""
if workflow_name not in self.registry:
logger.error(
f"Workflow '{workflow_name}' not found in registry. "
f"Available workflows: {list(self.registry.keys())}"
)
return None
try:
from toolbox.workflows.registry import get_workflow_flow
flow_func = get_workflow_flow(workflow_name)
logger.debug(f"Retrieved flow function for '{workflow_name}' from registry")
return flow_func
except Exception as e:
logger.error(f"Failed to get flow function for '{workflow_name}': {e}")
return None
def get_registry_info(self, workflow_name: str) -> Optional[Dict[str, Any]]:
"""
Get registry information for a workflow.
Args:
workflow_name: Name of the workflow
Returns:
Registry information if found, None otherwise
"""
if workflow_name not in self.registry:
return None
try:
from toolbox.workflows.registry import get_workflow_info
return get_workflow_info(workflow_name)
except Exception as e:
logger.error(f"Failed to get registry info for '{workflow_name}': {e}")
return None
@staticmethod
def get_metadata_schema() -> Dict[str, Any]:
"""
Get the JSON schema for workflow metadata.
Returns:
JSON schema dictionary
"""
return {
"type": "object",
"required": ["name", "version", "description", "author", "category", "parameters", "requirements"],
"properties": {
"name": {
"type": "string",
"description": "Workflow name"
},
"version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+$",
"description": "Semantic version (x.y.z)"
},
"description": {
"type": "string",
"description": "Workflow description"
},
"author": {
"type": "string",
"description": "Workflow author"
},
"category": {
"type": "string",
"enum": ["comprehensive", "specialized", "fuzzing", "focused"],
"description": "Workflow category"
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Workflow tags for categorization"
},
"requirements": {
"type": "object",
"required": ["tools", "resources"],
"properties": {
"tools": {
"type": "array",
"items": {"type": "string"},
"description": "Required security tools"
},
"resources": {
"type": "object",
"required": ["memory", "cpu", "timeout"],
"properties": {
"memory": {
"type": "string",
"pattern": "^\\d+[GMK]i$",
"description": "Memory limit (e.g., 1Gi, 512Mi)"
},
"cpu": {
"type": "string",
"pattern": "^\\d+m?$",
"description": "CPU limit (e.g., 1000m, 2)"
},
"timeout": {
"type": "integer",
"minimum": 60,
"maximum": 7200,
"description": "Workflow timeout in seconds"
}
}
}
}
},
"parameters": {
"type": "object",
"description": "Workflow parameters schema"
},
"default_parameters": {
"type": "object",
"description": "Default parameter values"
},
"required_modules": {
"type": "array",
"items": {"type": "string"},
"description": "Required module names"
},
"supported_volume_modes": {
"type": "array",
"items": {"enum": ["ro", "rw"]},
"default": ["ro", "rw"],
"description": "Supported volume mount modes"
},
"flow_function": {
"type": "string",
"default": "main_flow",
"description": "Name of the flow function in workflow.py"
}
}
}
+864
View File
@@ -0,0 +1,864 @@
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import asyncio
import logging
import os
from uuid import UUID
from contextlib import AsyncExitStack, asynccontextmanager, suppress
from typing import Any, Dict, Optional, List
import uvicorn
from fastapi import FastAPI
from starlette.applications import Starlette
from starlette.routing import Mount
from fastmcp.server.http import create_sse_app
from src.core.prefect_manager import PrefectManager
from src.core.setup import setup_docker_pool, setup_result_storage, validate_infrastructure
from src.core.workflow_discovery import WorkflowDiscovery
from src.api import workflows, runs, fuzzing
from src.services.prefect_stats_monitor import prefect_stats_monitor
from fastmcp import FastMCP
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import (
FlowRunFilter,
FlowRunFilterDeploymentId,
FlowRunFilterState,
FlowRunFilterStateType,
)
from prefect.client.schemas.sorting import FlowRunSort
from prefect.states import StateType
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
prefect_mgr = PrefectManager()
class PrefectBootstrapState:
"""Tracks Prefect initialization progress for API and MCP consumers."""
def __init__(self) -> None:
self.ready: bool = False
self.status: str = "not_started"
self.last_error: Optional[str] = None
self.task_running: bool = False
def as_dict(self) -> Dict[str, Any]:
return {
"ready": self.ready,
"status": self.status,
"last_error": self.last_error,
"task_running": self.task_running,
}
prefect_bootstrap_state = PrefectBootstrapState()
# Configure retry strategy for bootstrapping Prefect + infrastructure
STARTUP_RETRY_SECONDS = max(1, int(os.getenv("FUZZFORGE_STARTUP_RETRY_SECONDS", "5")))
STARTUP_RETRY_MAX_SECONDS = max(
STARTUP_RETRY_SECONDS,
int(os.getenv("FUZZFORGE_STARTUP_RETRY_MAX_SECONDS", "60")),
)
prefect_bootstrap_task: Optional[asyncio.Task] = None
# ---------------------------------------------------------------------------
# FastAPI application (REST API remains unchanged)
# ---------------------------------------------------------------------------
app = FastAPI(
title="FuzzForge API",
description="Security testing workflow orchestration API with fuzzing support",
version="0.6.0",
)
app.include_router(workflows.router)
app.include_router(runs.router)
app.include_router(fuzzing.router)
def get_prefect_status() -> Dict[str, Any]:
"""Return a snapshot of Prefect bootstrap state for diagnostics."""
status = prefect_bootstrap_state.as_dict()
status["workflows_loaded"] = len(prefect_mgr.workflows)
status["deployments_tracked"] = len(prefect_mgr.deployments)
status["bootstrap_task_running"] = (
prefect_bootstrap_task is not None and not prefect_bootstrap_task.done()
)
return status
def _prefect_not_ready_status() -> Optional[Dict[str, Any]]:
"""Return status details if Prefect is not ready yet."""
status = get_prefect_status()
if status.get("ready"):
return None
return status
@app.get("/")
async def root() -> Dict[str, Any]:
status = get_prefect_status()
return {
"name": "FuzzForge API",
"version": "0.6.0",
"status": "ready" if status.get("ready") else "initializing",
"workflows_loaded": status.get("workflows_loaded", 0),
"prefect": status,
}
@app.get("/health")
async def health() -> Dict[str, str]:
status = get_prefect_status()
health_status = "healthy" if status.get("ready") else "initializing"
return {"status": health_status}
# Map FastAPI OpenAPI operationIds to readable MCP tool names
FASTAPI_MCP_NAME_OVERRIDES: Dict[str, str] = {
"list_workflows_workflows__get": "api_list_workflows",
"get_metadata_schema_workflows_metadata_schema_get": "api_get_metadata_schema",
"get_workflow_metadata_workflows__workflow_name__metadata_get": "api_get_workflow_metadata",
"submit_workflow_workflows__workflow_name__submit_post": "api_submit_workflow",
"get_workflow_parameters_workflows__workflow_name__parameters_get": "api_get_workflow_parameters",
"get_run_status_runs__run_id__status_get": "api_get_run_status",
"get_run_findings_runs__run_id__findings_get": "api_get_run_findings",
"get_workflow_findings_runs__workflow_name__findings__run_id__get": "api_get_workflow_findings",
"get_fuzzing_stats_fuzzing__run_id__stats_get": "api_get_fuzzing_stats",
"update_fuzzing_stats_fuzzing__run_id__stats_post": "api_update_fuzzing_stats",
"get_crash_reports_fuzzing__run_id__crashes_get": "api_get_crash_reports",
"report_crash_fuzzing__run_id__crash_post": "api_report_crash",
"stream_fuzzing_updates_fuzzing__run_id__stream_get": "api_stream_fuzzing_updates",
"cleanup_fuzzing_run_fuzzing__run_id__delete": "api_cleanup_fuzzing_run",
"root__get": "api_root",
"health_health_get": "api_health",
}
# Create an MCP adapter exposing all FastAPI endpoints via OpenAPI parsing
FASTAPI_MCP_ADAPTER = FastMCP.from_fastapi(
app,
name="FuzzForge FastAPI",
mcp_names=FASTAPI_MCP_NAME_OVERRIDES,
)
_fastapi_mcp_imported = False
# ---------------------------------------------------------------------------
# FastMCP server (runs on dedicated port outside FastAPI)
# ---------------------------------------------------------------------------
mcp = FastMCP(name="FuzzForge MCP")
async def _bootstrap_prefect_with_retries() -> None:
"""Initialize Prefect infrastructure with exponential backoff retries."""
attempt = 0
while True:
attempt += 1
prefect_bootstrap_state.task_running = True
prefect_bootstrap_state.status = "starting"
prefect_bootstrap_state.ready = False
prefect_bootstrap_state.last_error = None
try:
logger.info("Bootstrapping Prefect infrastructure...")
await validate_infrastructure()
await setup_docker_pool()
await setup_result_storage()
await prefect_mgr.initialize()
await prefect_stats_monitor.start_monitoring()
prefect_bootstrap_state.ready = True
prefect_bootstrap_state.status = "ready"
prefect_bootstrap_state.task_running = False
logger.info("Prefect infrastructure ready")
return
except asyncio.CancelledError:
prefect_bootstrap_state.status = "cancelled"
prefect_bootstrap_state.task_running = False
logger.info("Prefect bootstrap task cancelled")
raise
except Exception as exc: # pragma: no cover - defensive logging on infra startup
logger.exception("Prefect bootstrap failed")
prefect_bootstrap_state.ready = False
prefect_bootstrap_state.status = "error"
prefect_bootstrap_state.last_error = str(exc)
# Ensure partial initialization does not leave stale state behind
prefect_mgr.workflows.clear()
prefect_mgr.deployments.clear()
await prefect_stats_monitor.stop_monitoring()
wait_time = min(
STARTUP_RETRY_SECONDS * (2 ** (attempt - 1)),
STARTUP_RETRY_MAX_SECONDS,
)
logger.info("Retrying Prefect bootstrap in %s second(s)", wait_time)
try:
await asyncio.sleep(wait_time)
except asyncio.CancelledError:
prefect_bootstrap_state.status = "cancelled"
prefect_bootstrap_state.task_running = False
raise
def _lookup_workflow(workflow_name: str):
info = prefect_mgr.workflows.get(workflow_name)
if not info:
return None
metadata = info.metadata
defaults = metadata.get("default_parameters", {})
default_target_path = metadata.get("default_target_path") or defaults.get("target_path")
supported_modes = metadata.get("supported_volume_modes") or ["ro", "rw"]
if not isinstance(supported_modes, list) or not supported_modes:
supported_modes = ["ro", "rw"]
default_volume_mode = (
metadata.get("default_volume_mode")
or defaults.get("volume_mode")
or supported_modes[0]
)
return {
"name": workflow_name,
"version": metadata.get("version", "0.6.0"),
"description": metadata.get("description", ""),
"author": metadata.get("author"),
"tags": metadata.get("tags", []),
"parameters": metadata.get("parameters", {}),
"default_parameters": metadata.get("default_parameters", {}),
"required_modules": metadata.get("required_modules", []),
"supported_volume_modes": supported_modes,
"default_target_path": default_target_path,
"default_volume_mode": default_volume_mode,
"has_custom_docker": bool(info.has_docker),
}
@mcp.tool
async def list_workflows_mcp() -> Dict[str, Any]:
"""List all discovered workflows and their metadata summary."""
not_ready = _prefect_not_ready_status()
if not_ready:
return {
"workflows": [],
"prefect": not_ready,
"message": "Prefect infrastructure is still initializing",
}
workflows_summary = []
for name, info in prefect_mgr.workflows.items():
metadata = info.metadata
defaults = metadata.get("default_parameters", {})
workflows_summary.append({
"name": name,
"version": metadata.get("version", "0.6.0"),
"description": metadata.get("description", ""),
"author": metadata.get("author"),
"tags": metadata.get("tags", []),
"supported_volume_modes": metadata.get("supported_volume_modes", ["ro", "rw"]),
"default_volume_mode": metadata.get("default_volume_mode")
or defaults.get("volume_mode")
or "ro",
"default_target_path": metadata.get("default_target_path")
or defaults.get("target_path"),
"has_custom_docker": bool(info.has_docker),
})
return {"workflows": workflows_summary, "prefect": get_prefect_status()}
@mcp.tool
async def get_workflow_metadata_mcp(workflow_name: str) -> Dict[str, Any]:
"""Fetch detailed metadata for a workflow."""
not_ready = _prefect_not_ready_status()
if not_ready:
return {
"error": "Prefect infrastructure not ready",
"prefect": not_ready,
}
data = _lookup_workflow(workflow_name)
if not data:
return {"error": f"Workflow not found: {workflow_name}"}
return data
@mcp.tool
async def get_workflow_parameters_mcp(workflow_name: str) -> Dict[str, Any]:
"""Return the parameter schema and defaults for a workflow."""
not_ready = _prefect_not_ready_status()
if not_ready:
return {
"error": "Prefect infrastructure not ready",
"prefect": not_ready,
}
data = _lookup_workflow(workflow_name)
if not data:
return {"error": f"Workflow not found: {workflow_name}"}
return {
"parameters": data.get("parameters", {}),
"defaults": data.get("default_parameters", {}),
}
@mcp.tool
async def get_workflow_metadata_schema_mcp() -> Dict[str, Any]:
"""Return the JSON schema describing workflow metadata files."""
return WorkflowDiscovery.get_metadata_schema()
@mcp.tool
async def submit_security_scan_mcp(
workflow_name: str,
target_path: str | None = None,
volume_mode: str | None = None,
parameters: Dict[str, Any] | None = None,
) -> Dict[str, Any] | Dict[str, str]:
"""Submit a Prefect workflow via MCP."""
try:
not_ready = _prefect_not_ready_status()
if not_ready:
return {
"error": "Prefect infrastructure not ready",
"prefect": not_ready,
}
workflow_info = prefect_mgr.workflows.get(workflow_name)
if not workflow_info:
return {"error": f"Workflow '{workflow_name}' not found"}
metadata = workflow_info.metadata or {}
defaults = metadata.get("default_parameters", {})
resolved_target_path = target_path or metadata.get("default_target_path") or defaults.get("target_path")
if not resolved_target_path:
return {
"error": (
"target_path is required and no default_target_path is defined in metadata"
),
"metadata": {
"workflow": workflow_name,
"default_target_path": metadata.get("default_target_path"),
},
}
requested_volume_mode = volume_mode or metadata.get("default_volume_mode") or defaults.get("volume_mode")
if not requested_volume_mode:
requested_volume_mode = "ro"
normalised_volume_mode = (
str(requested_volume_mode).strip().lower().replace("-", "_")
)
if normalised_volume_mode in {"read_only", "readonly", "ro"}:
normalised_volume_mode = "ro"
elif normalised_volume_mode in {"read_write", "readwrite", "rw"}:
normalised_volume_mode = "rw"
else:
supported_modes = metadata.get("supported_volume_modes", ["ro", "rw"])
if isinstance(supported_modes, list) and normalised_volume_mode in supported_modes:
pass
else:
normalised_volume_mode = "ro"
parameters = parameters or {}
cleaned_parameters: Dict[str, Any] = {**defaults, **parameters}
# Ensure *_config structures default to dicts so Prefect validation passes.
for key, value in list(cleaned_parameters.items()):
if isinstance(key, str) and key.endswith("_config") and value is None:
cleaned_parameters[key] = {}
# Some workflows expect configuration dictionaries even when omitted.
parameter_definitions = (
metadata.get("parameters", {}).get("properties", {})
if isinstance(metadata.get("parameters"), dict)
else {}
)
for key, definition in parameter_definitions.items():
if not isinstance(key, str) or not key.endswith("_config"):
continue
if key not in cleaned_parameters:
default_value = definition.get("default") if isinstance(definition, dict) else None
cleaned_parameters[key] = default_value if default_value is not None else {}
elif cleaned_parameters[key] is None:
cleaned_parameters[key] = {}
flow_run = await prefect_mgr.submit_workflow(
workflow_name=workflow_name,
target_path=resolved_target_path,
volume_mode=normalised_volume_mode,
parameters=cleaned_parameters,
)
return {
"run_id": str(flow_run.id),
"status": flow_run.state.name if flow_run.state else "PENDING",
"workflow": workflow_name,
"message": f"Workflow '{workflow_name}' submitted successfully",
"target_path": resolved_target_path,
"volume_mode": normalised_volume_mode,
"parameters": cleaned_parameters,
"mcp_enabled": True,
}
except Exception as exc: # pragma: no cover - defensive logging
logger.exception("MCP submit failed")
return {"error": f"Failed to submit workflow: {exc}"}
@mcp.tool
async def get_comprehensive_scan_summary(run_id: str) -> Dict[str, Any] | Dict[str, str]:
"""Return a summary for the given flow run via MCP."""
try:
not_ready = _prefect_not_ready_status()
if not_ready:
return {
"error": "Prefect infrastructure not ready",
"prefect": not_ready,
}
status = await prefect_mgr.get_flow_run_status(run_id)
findings = await prefect_mgr.get_flow_run_findings(run_id)
workflow_name = "unknown"
deployment_id = status.get("workflow", "")
for name, deployment in prefect_mgr.deployments.items():
if str(deployment) == str(deployment_id):
workflow_name = name
break
total_findings = 0
severity_summary = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
if findings and "sarif" in findings:
sarif = findings["sarif"]
if isinstance(sarif, dict):
total_findings = sarif.get("total_findings", 0)
return {
"run_id": run_id,
"workflow": workflow_name,
"status": status.get("status", "unknown"),
"is_completed": status.get("is_completed", False),
"total_findings": total_findings,
"severity_summary": severity_summary,
"scan_duration": status.get("updated_at", "")
if status.get("is_completed")
else "In progress",
"recommendations": (
[
"Review high and critical severity findings first",
"Implement security fixes based on finding recommendations",
"Re-run scan after applying fixes to verify remediation",
]
if total_findings > 0
else ["No security issues found"]
),
"mcp_analysis": True,
}
except Exception as exc: # pragma: no cover
logger.exception("MCP summary failed")
return {"error": f"Failed to summarize run: {exc}"}
@mcp.tool
async def get_run_status_mcp(run_id: str) -> Dict[str, Any]:
"""Return current status information for a Prefect run."""
try:
not_ready = _prefect_not_ready_status()
if not_ready:
return {
"error": "Prefect infrastructure not ready",
"prefect": not_ready,
}
status = await prefect_mgr.get_flow_run_status(run_id)
workflow_name = "unknown"
deployment_id = status.get("workflow", "")
for name, deployment in prefect_mgr.deployments.items():
if str(deployment) == str(deployment_id):
workflow_name = name
break
return {
"run_id": status["run_id"],
"workflow": workflow_name,
"status": status["status"],
"is_completed": status["is_completed"],
"is_failed": status["is_failed"],
"is_running": status["is_running"],
"created_at": status["created_at"],
"updated_at": status["updated_at"],
}
except Exception as exc:
logger.exception("MCP run status failed")
return {"error": f"Failed to get run status: {exc}"}
@mcp.tool
async def get_run_findings_mcp(run_id: str) -> Dict[str, Any]:
"""Return SARIF findings for a completed run."""
try:
not_ready = _prefect_not_ready_status()
if not_ready:
return {
"error": "Prefect infrastructure not ready",
"prefect": not_ready,
}
status = await prefect_mgr.get_flow_run_status(run_id)
if not status.get("is_completed"):
return {"error": f"Run {run_id} not completed. Status: {status.get('status')}"}
findings = await prefect_mgr.get_flow_run_findings(run_id)
workflow_name = "unknown"
deployment_id = status.get("workflow", "")
for name, deployment in prefect_mgr.deployments.items():
if str(deployment) == str(deployment_id):
workflow_name = name
break
metadata = {
"completion_time": status.get("updated_at"),
"workflow_version": "unknown",
}
info = prefect_mgr.workflows.get(workflow_name)
if info:
metadata["workflow_version"] = info.metadata.get("version", "unknown")
return {
"workflow": workflow_name,
"run_id": run_id,
"sarif": findings,
"metadata": metadata,
}
except Exception as exc:
logger.exception("MCP findings failed")
return {"error": f"Failed to retrieve findings: {exc}"}
@mcp.tool
async def list_recent_runs_mcp(
limit: int = 10,
workflow_name: str | None = None,
states: List[str] | None = None,
) -> Dict[str, Any]:
"""List recent Prefect runs with optional workflow/state filters."""
not_ready = _prefect_not_ready_status()
if not_ready:
return {
"runs": [],
"prefect": not_ready,
"message": "Prefect infrastructure is still initializing",
}
try:
limit_value = int(limit)
except (TypeError, ValueError):
limit_value = 10
limit_value = max(1, min(limit_value, 100))
deployment_map = {
str(deployment_id): workflow
for workflow, deployment_id in prefect_mgr.deployments.items()
}
deployment_filter_value = None
if workflow_name:
deployment_id = prefect_mgr.deployments.get(workflow_name)
if not deployment_id:
return {
"runs": [],
"prefect": get_prefect_status(),
"error": f"Workflow '{workflow_name}' has no registered deployment",
}
try:
deployment_filter_value = UUID(str(deployment_id))
except ValueError:
return {
"runs": [],
"prefect": get_prefect_status(),
"error": (
f"Deployment id '{deployment_id}' for workflow '{workflow_name}' is invalid"
),
}
desired_state_types: List[StateType] = []
if states:
for raw_state in states:
if not raw_state:
continue
normalised = raw_state.strip().upper()
if normalised == "ALL":
desired_state_types = []
break
try:
desired_state_types.append(StateType[normalised])
except KeyError:
continue
if not desired_state_types:
desired_state_types = [
StateType.RUNNING,
StateType.COMPLETED,
StateType.FAILED,
StateType.CANCELLED,
]
flow_filter = FlowRunFilter()
if desired_state_types:
flow_filter.state = FlowRunFilterState(
type=FlowRunFilterStateType(any_=desired_state_types)
)
if deployment_filter_value:
flow_filter.deployment_id = FlowRunFilterDeploymentId(
any_=[deployment_filter_value]
)
async with get_client() as client:
flow_runs = await client.read_flow_runs(
limit=limit_value,
flow_run_filter=flow_filter,
sort=FlowRunSort.START_TIME_DESC,
)
results: List[Dict[str, Any]] = []
for flow_run in flow_runs:
deployment_id = getattr(flow_run, "deployment_id", None)
workflow = deployment_map.get(str(deployment_id), "unknown")
state = getattr(flow_run, "state", None)
state_name = getattr(state, "name", None) if state else None
state_type = getattr(state, "type", None) if state else None
results.append(
{
"run_id": str(flow_run.id),
"workflow": workflow,
"deployment_id": str(deployment_id) if deployment_id else None,
"state": state_name or (state_type.name if state_type else None),
"state_type": state_type.name if state_type else None,
"is_completed": bool(getattr(state, "is_completed", lambda: False)()),
"is_running": bool(getattr(state, "is_running", lambda: False)()),
"is_failed": bool(getattr(state, "is_failed", lambda: False)()),
"created_at": getattr(flow_run, "created", None),
"updated_at": getattr(flow_run, "updated", None),
"expected_start_time": getattr(flow_run, "expected_start_time", None),
"start_time": getattr(flow_run, "start_time", None),
}
)
# Normalise datetimes to ISO 8601 strings for serialization
for entry in results:
for key in ("created_at", "updated_at", "expected_start_time", "start_time"):
value = entry.get(key)
if value is None:
continue
try:
entry[key] = value.isoformat()
except AttributeError:
entry[key] = str(value)
return {"runs": results, "prefect": get_prefect_status()}
@mcp.tool
async def get_fuzzing_stats_mcp(run_id: str) -> Dict[str, Any]:
"""Return fuzzing statistics for a run if available."""
not_ready = _prefect_not_ready_status()
if not_ready:
return {
"error": "Prefect infrastructure not ready",
"prefect": not_ready,
}
stats = fuzzing.fuzzing_stats.get(run_id)
if not stats:
return {"error": f"Fuzzing run not found: {run_id}"}
# Be resilient if a plain dict slipped into the cache
if isinstance(stats, dict):
return stats
if hasattr(stats, "model_dump"):
return stats.model_dump()
if hasattr(stats, "dict"):
return stats.dict()
# Last resort
return getattr(stats, "__dict__", {"run_id": run_id})
@mcp.tool
async def get_fuzzing_crash_reports_mcp(run_id: str) -> Dict[str, Any]:
"""Return crash reports collected for a fuzzing run."""
not_ready = _prefect_not_ready_status()
if not_ready:
return {
"error": "Prefect infrastructure not ready",
"prefect": not_ready,
}
reports = fuzzing.crash_reports.get(run_id)
if reports is None:
return {"error": f"Fuzzing run not found: {run_id}"}
return {"run_id": run_id, "crashes": [report.model_dump() for report in reports]}
@mcp.tool
async def get_backend_status_mcp() -> Dict[str, Any]:
"""Expose backend readiness, workflows, and registered MCP tools."""
status = get_prefect_status()
response: Dict[str, Any] = {"prefect": status}
if status.get("ready"):
response["workflows"] = list(prefect_mgr.workflows.keys())
try:
tools = await mcp._tool_manager.list_tools()
response["mcp_tools"] = sorted(tool.name for tool in tools)
except Exception as exc: # pragma: no cover - defensive logging
logger.debug("Failed to enumerate MCP tools: %s", exc)
return response
def create_mcp_transport_app() -> Starlette:
"""Build a Starlette app serving HTTP + SSE transports on one port."""
http_app = mcp.http_app(path="/", transport="streamable-http")
sse_app = create_sse_app(
server=mcp,
message_path="/messages",
sse_path="/",
auth=mcp.auth,
)
routes = [
Mount("/mcp", app=http_app),
Mount("/mcp/sse", app=sse_app),
]
@asynccontextmanager
async def lifespan(app: Starlette): # pragma: no cover - integration wiring
async with AsyncExitStack() as stack:
await stack.enter_async_context(
http_app.router.lifespan_context(http_app)
)
await stack.enter_async_context(
sse_app.router.lifespan_context(sse_app)
)
yield
combined_app = Starlette(routes=routes, lifespan=lifespan)
combined_app.state.fastmcp_server = mcp
combined_app.state.http_app = http_app
combined_app.state.sse_app = sse_app
return combined_app
# ---------------------------------------------------------------------------
# Combined lifespan: Prefect init + dedicated MCP transports
# ---------------------------------------------------------------------------
@asynccontextmanager
async def combined_lifespan(app: FastAPI):
global prefect_bootstrap_task, _fastapi_mcp_imported
logger.info("Starting FuzzForge backend...")
# Ensure FastAPI endpoints are exposed via MCP once
if not _fastapi_mcp_imported:
try:
await mcp.import_server(FASTAPI_MCP_ADAPTER)
_fastapi_mcp_imported = True
logger.info("Mounted FastAPI endpoints as MCP tools")
except Exception as exc:
logger.exception("Failed to import FastAPI endpoints into MCP", exc_info=exc)
# Kick off Prefect bootstrap in the background if needed
if prefect_bootstrap_task is None or prefect_bootstrap_task.done():
prefect_bootstrap_task = asyncio.create_task(_bootstrap_prefect_with_retries())
logger.info("Prefect bootstrap task started")
else:
logger.info("Prefect bootstrap task already running")
# Start MCP transports on shared port (HTTP + SSE)
mcp_app = create_mcp_transport_app()
mcp_config = uvicorn.Config(
app=mcp_app,
host="0.0.0.0",
port=8010,
log_level="info",
lifespan="on",
)
mcp_server = uvicorn.Server(mcp_config)
mcp_server.install_signal_handlers = lambda: None # type: ignore[assignment]
mcp_task = asyncio.create_task(mcp_server.serve())
async def _wait_for_uvicorn_startup() -> None:
started_attr = getattr(mcp_server, "started", None)
if hasattr(started_attr, "wait"):
await asyncio.wait_for(started_attr.wait(), timeout=10)
return
# Fallback for uvicorn versions where "started" is a bool
poll_interval = 0.1
checks = int(10 / poll_interval)
for _ in range(checks):
if getattr(mcp_server, "started", False):
return
await asyncio.sleep(poll_interval)
raise asyncio.TimeoutError
try:
await _wait_for_uvicorn_startup()
except asyncio.TimeoutError: # pragma: no cover - defensive logging
if mcp_task.done():
raise RuntimeError("MCP server failed to start") from mcp_task.exception()
logger.warning("Timed out waiting for MCP server startup; continuing anyway")
logger.info("MCP HTTP available at http://0.0.0.0:8010/mcp")
logger.info("MCP SSE available at http://0.0.0.0:8010/mcp/sse")
try:
yield
finally:
logger.info("Shutting down MCP transports...")
mcp_server.should_exit = True
mcp_server.force_exit = True
await asyncio.gather(mcp_task, return_exceptions=True)
if prefect_bootstrap_task and not prefect_bootstrap_task.done():
prefect_bootstrap_task.cancel()
with suppress(asyncio.CancelledError):
await prefect_bootstrap_task
prefect_bootstrap_state.task_running = False
if not prefect_bootstrap_state.ready:
prefect_bootstrap_state.status = "stopped"
prefect_bootstrap_state.next_retry_seconds = None
prefect_bootstrap_task = None
logger.info("Shutting down Prefect statistics monitor...")
await prefect_stats_monitor.stop_monitoring()
logger.info("Shutting down FuzzForge backend...")
app.router.lifespan_context = combined_lifespan
+11
View File
@@ -0,0 +1,11 @@
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
+182
View File
@@ -0,0 +1,182 @@
"""
Models for workflow findings and submissions
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
from pydantic import BaseModel, Field, field_validator
from typing import Dict, Any, Optional, Literal, List
from datetime import datetime
from pathlib import Path
class WorkflowFindings(BaseModel):
"""Findings from a workflow execution in SARIF format"""
workflow: str = Field(..., description="Workflow name")
run_id: str = Field(..., description="Unique run identifier")
sarif: Dict[str, Any] = Field(..., description="SARIF formatted findings")
metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata")
class ResourceLimits(BaseModel):
"""Resource limits for workflow execution"""
cpu_limit: Optional[str] = Field(None, description="CPU limit (e.g., '2' for 2 cores, '500m' for 0.5 cores)")
memory_limit: Optional[str] = Field(None, description="Memory limit (e.g., '1Gi', '512Mi')")
cpu_request: Optional[str] = Field(None, description="CPU request (guaranteed)")
memory_request: Optional[str] = Field(None, description="Memory request (guaranteed)")
class VolumeMount(BaseModel):
"""Volume mount specification"""
host_path: str = Field(..., description="Host path to mount")
container_path: str = Field(..., description="Container path for mount")
mode: Literal["ro", "rw"] = Field(default="ro", description="Mount mode")
@field_validator("host_path")
@classmethod
def validate_host_path(cls, v):
"""Validate that the host path is absolute (existence checked at runtime)"""
path = Path(v)
if not path.is_absolute():
raise ValueError(f"Host path must be absolute: {v}")
# Note: Path existence is validated at workflow runtime
# We can't validate existence here as this runs inside Docker container
return str(path)
@field_validator("container_path")
@classmethod
def validate_container_path(cls, v):
"""Validate that the container path is absolute"""
if not v.startswith('/'):
raise ValueError(f"Container path must be absolute: {v}")
return v
class WorkflowSubmission(BaseModel):
"""Submit a workflow with configurable settings"""
target_path: str = Field(..., description="Absolute path to analyze")
volume_mode: Literal["ro", "rw"] = Field(
default="ro",
description="Volume mount mode: read-only (ro) or read-write (rw)"
)
parameters: Dict[str, Any] = Field(
default_factory=dict,
description="Workflow-specific parameters"
)
timeout: Optional[int] = Field(
default=None, # Allow workflow-specific defaults
description="Timeout in seconds (None for workflow default)",
ge=1,
le=604800 # Max 7 days to support fuzzing campaigns
)
resource_limits: Optional[ResourceLimits] = Field(
None,
description="Resource limits for workflow container"
)
additional_volumes: List[VolumeMount] = Field(
default_factory=list,
description="Additional volume mounts (e.g., for corpus, output directories)"
)
@field_validator("target_path")
@classmethod
def validate_path(cls, v):
"""Validate that the target path is absolute (existence checked at runtime)"""
path = Path(v)
if not path.is_absolute():
raise ValueError(f"Path must be absolute: {v}")
# Note: Path existence is validated at workflow runtime when volumes are mounted
# We can't validate existence here as this runs inside Docker container
return str(path)
class WorkflowStatus(BaseModel):
"""Status of a workflow run"""
run_id: str = Field(..., description="Unique run identifier")
workflow: str = Field(..., description="Workflow name")
status: str = Field(..., description="Current status")
is_completed: bool = Field(..., description="Whether the run is completed")
is_failed: bool = Field(..., description="Whether the run failed")
is_running: bool = Field(..., description="Whether the run is currently running")
created_at: datetime = Field(..., description="Run creation time")
updated_at: datetime = Field(..., description="Last update time")
class WorkflowMetadata(BaseModel):
"""Complete metadata for a workflow"""
name: str = Field(..., description="Workflow name")
version: str = Field(..., description="Semantic version")
description: str = Field(..., description="Workflow description")
author: Optional[str] = Field(None, description="Workflow author")
tags: List[str] = Field(default_factory=list, description="Workflow tags")
parameters: Dict[str, Any] = Field(..., description="Parameters schema")
default_parameters: Dict[str, Any] = Field(
default_factory=dict,
description="Default parameter values"
)
required_modules: List[str] = Field(
default_factory=list,
description="Required module names"
)
supported_volume_modes: List[Literal["ro", "rw"]] = Field(
default=["ro", "rw"],
description="Supported volume mount modes"
)
has_custom_docker: bool = Field(
default=False,
description="Whether workflow has custom Dockerfile"
)
class WorkflowListItem(BaseModel):
"""Summary information for a workflow in list views"""
name: str = Field(..., description="Workflow name")
version: str = Field(..., description="Semantic version")
description: str = Field(..., description="Workflow description")
author: Optional[str] = Field(None, description="Workflow author")
tags: List[str] = Field(default_factory=list, description="Workflow tags")
class RunSubmissionResponse(BaseModel):
"""Response after submitting a workflow"""
run_id: str = Field(..., description="Unique run identifier")
status: str = Field(..., description="Initial status")
workflow: str = Field(..., description="Workflow name")
message: str = Field(default="Workflow submitted successfully")
class FuzzingStats(BaseModel):
"""Real-time fuzzing statistics"""
run_id: str = Field(..., description="Unique run identifier")
workflow: str = Field(..., description="Workflow name")
executions: int = Field(default=0, description="Total executions")
executions_per_sec: float = Field(default=0.0, description="Current execution rate")
crashes: int = Field(default=0, description="Total crashes found")
unique_crashes: int = Field(default=0, description="Unique crashes")
coverage: Optional[float] = Field(None, description="Code coverage percentage")
corpus_size: int = Field(default=0, description="Current corpus size")
elapsed_time: int = Field(default=0, description="Elapsed time in seconds")
last_crash_time: Optional[datetime] = Field(None, description="Time of last crash")
class CrashReport(BaseModel):
"""Individual crash report from fuzzing"""
run_id: str = Field(..., description="Run identifier")
crash_id: str = Field(..., description="Unique crash identifier")
timestamp: datetime = Field(default_factory=datetime.utcnow)
signal: Optional[str] = Field(None, description="Crash signal (SIGSEGV, etc.)")
crash_type: Optional[str] = Field(None, description="Type of crash")
stack_trace: Optional[str] = Field(None, description="Stack trace")
input_file: Optional[str] = Field(None, description="Path to crashing input")
reproducer: Optional[str] = Field(None, description="Minimized reproducer")
severity: str = Field(default="medium", description="Crash severity")
exploitability: Optional[str] = Field(None, description="Exploitability assessment")
@@ -0,0 +1,394 @@
"""
Generic Prefect Statistics Monitor Service
This service monitors ALL workflows for structured live data logging and
updates the appropriate statistics APIs. Works with any workflow that follows
the standard LIVE_STATS logging pattern.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import asyncio
import json
import logging
from datetime import datetime, timedelta, timezone
from typing import Dict, Any, Optional
from prefect.client.orchestration import get_client
from prefect.client.schemas.objects import FlowRun, TaskRun
from src.models.findings import FuzzingStats
from src.api.fuzzing import fuzzing_stats, initialize_fuzzing_tracking, active_connections
logger = logging.getLogger(__name__)
class PrefectStatsMonitor:
"""Monitors Prefect flows and tasks for live statistics from any workflow"""
def __init__(self):
self.monitoring = False
self.monitor_task = None
self.monitored_runs = set()
self.last_log_ts: Dict[str, datetime] = {}
self._client = None
self._client_refresh_time = None
self._client_refresh_interval = 300 # Refresh connection every 5 minutes
async def start_monitoring(self):
"""Start the Prefect statistics monitoring service"""
if self.monitoring:
logger.warning("Prefect stats monitor already running")
return
self.monitoring = True
self.monitor_task = asyncio.create_task(self._monitor_flows())
logger.info("Started Prefect statistics monitor")
async def stop_monitoring(self):
"""Stop the monitoring service"""
self.monitoring = False
if self.monitor_task:
self.monitor_task.cancel()
try:
await self.monitor_task
except asyncio.CancelledError:
pass
logger.info("Stopped Prefect statistics monitor")
async def _get_or_refresh_client(self):
"""Get or refresh Prefect client with connection pooling."""
now = datetime.now(timezone.utc)
if (self._client is None or
self._client_refresh_time is None or
(now - self._client_refresh_time).total_seconds() > self._client_refresh_interval):
if self._client:
try:
await self._client.aclose()
except Exception:
pass
self._client = get_client()
self._client_refresh_time = now
await self._client.__aenter__()
return self._client
async def _monitor_flows(self):
"""Main monitoring loop that watches Prefect flows"""
try:
while self.monitoring:
try:
# Use connection pooling for better performance
client = await self._get_or_refresh_client()
# Get recent flow runs (limit to reduce load)
flow_runs = await client.read_flow_runs(
limit=50,
sort="START_TIME_DESC",
)
# Only consider runs from the last 15 minutes
recent_cutoff = datetime.now(timezone.utc) - timedelta(minutes=15)
for flow_run in flow_runs:
created = getattr(flow_run, "created", None)
if created is None:
continue
try:
# Ensure timezone-aware comparison
if created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
if created >= recent_cutoff:
await self._monitor_flow_run(client, flow_run)
except Exception:
# If comparison fails, attempt monitoring anyway
await self._monitor_flow_run(client, flow_run)
await asyncio.sleep(5) # Check every 5 seconds
except Exception as e:
logger.error(f"Error in Prefect monitoring: {e}")
await asyncio.sleep(10)
except asyncio.CancelledError:
logger.info("Prefect monitoring cancelled")
except Exception as e:
logger.error(f"Fatal error in Prefect monitoring: {e}")
finally:
# Clean up client on exit
if self._client:
try:
await self._client.__aexit__(None, None, None)
except Exception:
pass
self._client = None
async def _monitor_flow_run(self, client, flow_run: FlowRun):
"""Monitor a specific flow run for statistics"""
run_id = str(flow_run.id)
workflow_name = flow_run.name or "unknown"
try:
# Initialize tracking if not exists - only for workflows that might have live stats
if run_id not in fuzzing_stats:
initialize_fuzzing_tracking(run_id, workflow_name)
self.monitored_runs.add(run_id)
# Skip corrupted entries (should not happen after startup cleanup, but defensive)
elif not isinstance(fuzzing_stats[run_id], FuzzingStats):
logger.warning(f"Skipping corrupted stats entry for {run_id}, reinitializing")
initialize_fuzzing_tracking(run_id, workflow_name)
self.monitored_runs.add(run_id)
# Get task runs for this flow
task_runs = await client.read_task_runs(
flow_run_filter={"id": {"any_": [flow_run.id]}},
limit=25,
)
# Check all tasks for live statistics logging
for task_run in task_runs:
await self._extract_stats_from_task(client, run_id, task_run, workflow_name)
# Also scan flow-level logs as a fallback
await self._extract_stats_from_flow_logs(client, run_id, flow_run, workflow_name)
except Exception as e:
logger.warning(f"Error monitoring flow run {run_id}: {e}")
async def _extract_stats_from_task(self, client, run_id: str, task_run: TaskRun, workflow_name: str):
"""Extract statistics from any task that logs live stats"""
try:
# Get task run logs
logs = await client.read_logs(
log_filter={
"task_run_id": {"any_": [task_run.id]}
},
limit=100,
sort="TIMESTAMP_ASC"
)
# Parse logs for LIVE_STATS entries (generic pattern for any workflow)
latest_stats = None
for log in logs:
# Prefer structured extra field if present
extra_data = getattr(log, "extra", None) or getattr(log, "extra_fields", None) or None
if isinstance(extra_data, dict):
stat_type = extra_data.get("stats_type")
if stat_type in ["fuzzing_live_update", "scan_progress", "analysis_update", "live_stats"]:
latest_stats = extra_data
continue
# Fallback to parsing from message text
if ("FUZZ_STATS" in log.message or "LIVE_STATS" in log.message):
stats = self._parse_stats_from_log(log.message)
if stats:
latest_stats = stats
# Update statistics if we found any
if latest_stats:
# Calculate elapsed time from task start
elapsed_time = 0
if task_run.start_time:
# Ensure timezone-aware arithmetic
now = datetime.now(timezone.utc)
try:
elapsed_time = int((now - task_run.start_time).total_seconds())
except Exception:
# Fallback to naive UTC if types mismatch
elapsed_time = int((datetime.utcnow() - task_run.start_time.replace(tzinfo=None)).total_seconds())
updated_stats = FuzzingStats(
run_id=run_id,
workflow=workflow_name,
executions=latest_stats.get("executions", 0),
executions_per_sec=latest_stats.get("executions_per_sec", 0.0),
crashes=latest_stats.get("crashes", 0),
unique_crashes=latest_stats.get("unique_crashes", 0),
corpus_size=latest_stats.get("corpus_size", 0),
elapsed_time=elapsed_time
)
# Update the global stats
previous = fuzzing_stats.get(run_id)
fuzzing_stats[run_id] = updated_stats
# Broadcast to any active WebSocket clients for this run
if active_connections.get(run_id):
# Handle both Pydantic objects and plain dicts
if isinstance(updated_stats, dict):
stats_data = updated_stats
elif hasattr(updated_stats, 'model_dump'):
stats_data = updated_stats.model_dump()
elif hasattr(updated_stats, 'dict'):
stats_data = updated_stats.dict()
else:
stats_data = updated_stats.__dict__
message = {
"type": "stats_update",
"data": stats_data,
}
disconnected = []
for ws in active_connections[run_id]:
try:
await ws.send_text(json.dumps(message))
except Exception:
disconnected.append(ws)
# Clean up disconnected sockets
for ws in disconnected:
try:
active_connections[run_id].remove(ws)
except ValueError:
pass
logger.debug(f"Updated Prefect stats for {run_id}: {updated_stats.executions} execs")
except Exception as e:
logger.warning(f"Error extracting stats from task {task_run.id}: {e}")
async def _extract_stats_from_flow_logs(self, client, run_id: str, flow_run: FlowRun, workflow_name: str):
"""Extract statistics by scanning flow-level logs for LIVE/FUZZ stats"""
try:
logs = await client.read_logs(
log_filter={
"flow_run_id": {"any_": [flow_run.id]}
},
limit=200,
sort="TIMESTAMP_ASC"
)
latest_stats = None
last_seen = self.last_log_ts.get(run_id)
max_ts = last_seen
for log in logs:
# Skip logs we've already processed
ts = getattr(log, "timestamp", None)
if last_seen and ts and ts <= last_seen:
continue
if ts and (max_ts is None or ts > max_ts):
max_ts = ts
# Prefer structured extra field if available
extra_data = getattr(log, "extra", None) or getattr(log, "extra_fields", None) or None
if isinstance(extra_data, dict):
stat_type = extra_data.get("stats_type")
if stat_type in ["fuzzing_live_update", "scan_progress", "analysis_update", "live_stats"]:
latest_stats = extra_data
continue
# Fallback to message parse
if ("FUZZ_STATS" in log.message or "LIVE_STATS" in log.message):
stats = self._parse_stats_from_log(log.message)
if stats:
latest_stats = stats
if max_ts:
self.last_log_ts[run_id] = max_ts
if latest_stats:
# Use flow_run timestamps for elapsed time if available
elapsed_time = 0
start_time = getattr(flow_run, "start_time", None) or getattr(flow_run, "start_time", None)
if start_time:
now = datetime.now(timezone.utc)
try:
if start_time.tzinfo is None:
start_time = start_time.replace(tzinfo=timezone.utc)
elapsed_time = int((now - start_time).total_seconds())
except Exception:
elapsed_time = int((datetime.utcnow() - start_time.replace(tzinfo=None)).total_seconds())
updated_stats = FuzzingStats(
run_id=run_id,
workflow=workflow_name,
executions=latest_stats.get("executions", 0),
executions_per_sec=latest_stats.get("executions_per_sec", 0.0),
crashes=latest_stats.get("crashes", 0),
unique_crashes=latest_stats.get("unique_crashes", 0),
corpus_size=latest_stats.get("corpus_size", 0),
elapsed_time=elapsed_time
)
fuzzing_stats[run_id] = updated_stats
# Broadcast if listeners exist
if active_connections.get(run_id):
# Handle both Pydantic objects and plain dicts
if isinstance(updated_stats, dict):
stats_data = updated_stats
elif hasattr(updated_stats, 'model_dump'):
stats_data = updated_stats.model_dump()
elif hasattr(updated_stats, 'dict'):
stats_data = updated_stats.dict()
else:
stats_data = updated_stats.__dict__
message = {
"type": "stats_update",
"data": stats_data,
}
disconnected = []
for ws in active_connections[run_id]:
try:
await ws.send_text(json.dumps(message))
except Exception:
disconnected.append(ws)
for ws in disconnected:
try:
active_connections[run_id].remove(ws)
except ValueError:
pass
except Exception as e:
logger.warning(f"Error extracting stats from flow logs {run_id}: {e}")
def _parse_stats_from_log(self, log_message: str) -> Optional[Dict[str, Any]]:
"""Parse statistics from a log message"""
try:
import re
# Prefer explicit JSON after marker tokens
m = re.search(r'(?:FUZZ_STATS|LIVE_STATS)\s+(\{.*\})', log_message)
if m:
try:
return json.loads(m.group(1))
except Exception:
pass
# Fallback: Extract the extra= dict and coerce to JSON
stats_match = re.search(r'extra=({.*?})', log_message)
if not stats_match:
return None
extra_str = stats_match.group(1)
extra_str = extra_str.replace("'", '"')
extra_str = extra_str.replace('None', 'null')
extra_str = extra_str.replace('True', 'true')
extra_str = extra_str.replace('False', 'false')
stats_data = json.loads(extra_str)
# Support multiple stat types for different workflows
stat_type = stats_data.get("stats_type")
if stat_type in ["fuzzing_live_update", "scan_progress", "analysis_update", "live_stats"]:
return stats_data
except Exception as e:
logger.debug(f"Error parsing log stats: {e}")
return None
# Global instance
prefect_stats_monitor = PrefectStatsMonitor()
+19
View File
@@ -0,0 +1,19 @@
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import sys
from pathlib import Path
# Ensure project root is on sys.path so `src` is importable
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
@@ -0,0 +1,82 @@
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import asyncio
from datetime import datetime, timezone, timedelta
from src.services.prefect_stats_monitor import PrefectStatsMonitor
from src.api import fuzzing
class FakeLog:
def __init__(self, message: str):
self.message = message
class FakeClient:
def __init__(self, logs):
self._logs = logs
async def read_logs(self, log_filter=None, limit=100, sort="TIMESTAMP_ASC"):
return self._logs
class FakeTaskRun:
def __init__(self):
self.id = "task-1"
self.start_time = datetime.now(timezone.utc) - timedelta(seconds=5)
def test_parse_stats_from_log_fuzzing():
mon = PrefectStatsMonitor()
msg = (
"INFO LIVE_STATS extra={'stats_type': 'fuzzing_live_update', "
"'executions': 42, 'executions_per_sec': 3.14, 'crashes': 1, 'unique_crashes': 1, 'corpus_size': 9}"
)
stats = mon._parse_stats_from_log(msg)
assert stats is not None
assert stats["stats_type"] == "fuzzing_live_update"
assert stats["executions"] == 42
def test_extract_stats_updates_and_broadcasts():
mon = PrefectStatsMonitor()
run_id = "run-123"
workflow = "wf"
fuzzing.initialize_fuzzing_tracking(run_id, workflow)
# Prepare a fake websocket to capture messages
sent = []
class FakeWS:
async def send_text(self, text: str):
sent.append(text)
fuzzing.active_connections[run_id] = [FakeWS()]
# Craft a log line the parser understands
msg = (
"INFO LIVE_STATS extra={'stats_type': 'fuzzing_live_update', "
"'executions': 10, 'executions_per_sec': 1.5, 'crashes': 0, 'unique_crashes': 0, 'corpus_size': 2}"
)
fake_client = FakeClient([FakeLog(msg)])
task_run = FakeTaskRun()
asyncio.run(mon._extract_stats_from_task(fake_client, run_id, task_run, workflow))
# Verify stats updated
stats = fuzzing.fuzzing_stats[run_id]
assert stats.executions == 10
assert stats.executions_per_sec == 1.5
# Verify a message was sent to WebSocket
assert sent, "Expected a stats_update message to be sent"
+11
View File
@@ -0,0 +1,11 @@
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
+11
View File
@@ -0,0 +1,11 @@
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
@@ -0,0 +1,37 @@
"""
AI Security Modules
This package contains modules for AI and machine learning model security testing.
Available modules:
- Garak: LLM/AI model security testing framework for prompt injection, bias, and jailbreaks
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
from typing import List, Type
from ..base import BaseModule
# Module registry for automatic discovery
AI_SECURITY_MODULES: List[Type[BaseModule]] = []
def register_module(module_class: Type[BaseModule]):
"""Register an AI security module"""
AI_SECURITY_MODULES.append(module_class)
return module_class
def get_available_modules() -> List[Type[BaseModule]]:
"""Get all available AI security modules"""
return AI_SECURITY_MODULES.copy()
# Import modules to trigger registration
from .garak import GarakModule
@@ -0,0 +1,526 @@
"""
Garak AI Security Module
This module uses Garak for AI red-teaming and LLM vulnerability assessment,
testing for prompt injection, bias, jailbreaks, and other AI-specific security issues.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import asyncio
import json
import os
from pathlib import Path
from typing import Dict, Any, List
import subprocess
import logging
from ..base import BaseModule, ModuleMetadata, ModuleFinding, ModuleResult
from . import register_module
logger = logging.getLogger(__name__)
@register_module
class GarakModule(BaseModule):
"""Garak AI red-teaming and LLM vulnerability assessment module"""
def get_metadata(self) -> ModuleMetadata:
"""Get module metadata"""
return ModuleMetadata(
name="garak",
version="0.9.0",
description="AI red-teaming framework for testing LLM vulnerabilities including prompt injection, bias, and jailbreaks",
author="FuzzForge Team",
category="ai_security",
tags=["ai", "llm", "prompt-injection", "bias", "jailbreak", "red-team"],
input_schema={
"type": "object",
"properties": {
"model_type": {
"type": "string",
"enum": ["openai", "huggingface", "anthropic", "local"],
"description": "Type of LLM to test"
},
"model_name": {
"type": "string",
"description": "Name/path of the model to test"
},
"api_key": {
"type": "string",
"description": "API key for cloud models (if required)"
},
"probes": {
"type": "array",
"items": {"type": "string"},
"default": ["encoding", "promptinject", "malwaregen", "dan"],
"description": "Probe types to run"
},
"generations": {
"type": "integer",
"default": 10,
"description": "Number of generations per probe"
},
"detectors": {
"type": "array",
"items": {"type": "string"},
"description": "Detectors to use for evaluation"
},
"config_file": {
"type": "string",
"description": "Path to Garak configuration file"
},
"report_prefix": {
"type": "string",
"default": "garak",
"description": "Prefix for report files"
},
"parallel_requests": {
"type": "integer",
"default": 1,
"description": "Number of parallel requests"
},
"temperature": {
"type": "number",
"default": 0.7,
"description": "Model temperature setting"
},
"max_tokens": {
"type": "integer",
"default": 150,
"description": "Maximum tokens per generation"
},
"seed": {
"type": "integer",
"description": "Random seed for reproducibility"
},
"verbose": {
"type": "boolean",
"default": false,
"description": "Enable verbose output"
}
}
},
output_schema={
"type": "object",
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"probe_name": {"type": "string"},
"vulnerability_type": {"type": "string"},
"success_rate": {"type": "number"},
"prompt": {"type": "string"},
"response": {"type": "string"}
}
}
}
}
}
)
def validate_config(self, config: Dict[str, Any]) -> bool:
"""Validate configuration"""
model_type = config.get("model_type")
if not model_type:
raise ValueError("model_type is required")
model_name = config.get("model_name")
if not model_name:
raise ValueError("model_name is required")
return True
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
"""Execute Garak AI security testing"""
self.start_timer()
try:
# Validate inputs
self.validate_config(config)
self.validate_workspace(workspace)
logger.info("Running Garak AI security assessment")
# Check Garak installation
await self._check_garak_installation()
# Run Garak testing
findings = await self._run_garak_assessment(config, workspace)
# Create summary
summary = self._create_summary(findings)
logger.info(f"Garak found {len(findings)} AI security issues")
return self.create_result(
findings=findings,
status="success",
summary=summary
)
except Exception as e:
logger.error(f"Garak module failed: {e}")
return self.create_result(
findings=[],
status="failed",
error=str(e)
)
async def _check_garak_installation(self):
"""Check if Garak is installed"""
try:
process = await asyncio.create_subprocess_exec(
"python", "-c", "import garak; print(garak.__version__)",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
# Try installing if not available
logger.info("Garak not found, attempting installation...")
install_process = await asyncio.create_subprocess_exec(
"pip", "install", "garak",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
await install_process.communicate()
except Exception as e:
logger.warning(f"Garak installation check failed: {e}")
async def _run_garak_assessment(self, config: Dict[str, Any], workspace: Path) -> List[ModuleFinding]:
"""Run Garak AI security assessment"""
findings = []
try:
# Build Garak command
cmd = ["python", "-m", "garak"]
# Add model configuration
cmd.extend(["--model_type", config["model_type"]])
cmd.extend(["--model_name", config["model_name"]])
# Add API key if provided
api_key = config.get("api_key")
if api_key:
# Set environment variable instead of command line for security
os.environ["GARAK_API_KEY"] = api_key
# Add probes
probes = config.get("probes", ["encoding", "promptinject"])
for probe in probes:
cmd.extend(["--probes", probe])
# Add generations
generations = config.get("generations", 10)
cmd.extend(["--generations", str(generations)])
# Add detectors if specified
detectors = config.get("detectors", [])
for detector in detectors:
cmd.extend(["--detectors", detector])
# Add parallel requests
parallel = config.get("parallel_requests", 1)
if parallel > 1:
cmd.extend(["--parallel_requests", str(parallel)])
# Add model parameters
temperature = config.get("temperature", 0.7)
cmd.extend(["--temperature", str(temperature)])
max_tokens = config.get("max_tokens", 150)
cmd.extend(["--max_tokens", str(max_tokens)])
# Add seed for reproducibility
seed = config.get("seed")
if seed:
cmd.extend(["--seed", str(seed)])
# Add configuration file
config_file = config.get("config_file")
if config_file:
config_path = workspace / config_file
if config_path.exists():
cmd.extend(["--config", str(config_path)])
# Set output directory
output_dir = workspace / "garak_output"
output_dir.mkdir(exist_ok=True)
cmd.extend(["--report_prefix", str(output_dir / config.get("report_prefix", "garak"))])
# Add verbose flag
if config.get("verbose", False):
cmd.append("--verbose")
logger.debug(f"Running command: {' '.join(cmd)}")
# Run Garak
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=workspace
)
stdout, stderr = await process.communicate()
# Parse results
findings = self._parse_garak_results(output_dir, workspace, stdout.decode(), stderr.decode())
except Exception as e:
logger.warning(f"Error running Garak assessment: {e}")
return findings
def _parse_garak_results(self, output_dir: Path, workspace: Path, stdout: str, stderr: str) -> List[ModuleFinding]:
"""Parse Garak output for findings"""
findings = []
try:
# Look for JSON report files
report_files = list(output_dir.glob("*.report.jsonl"))
for report_file in report_files:
findings.extend(self._parse_report_file(report_file, workspace))
# If no report files, try to parse stdout
if not findings:
findings = self._parse_stdout_output(stdout, stderr, workspace)
except Exception as e:
logger.warning(f"Error parsing Garak results: {e}")
return findings
def _parse_report_file(self, report_file: Path, workspace: Path) -> List[ModuleFinding]:
"""Parse Garak JSONL report file"""
findings = []
try:
with open(report_file, 'r') as f:
for line in f:
if line.strip():
data = json.loads(line)
finding = self._create_garak_finding(data, workspace, report_file)
if finding:
findings.append(finding)
except Exception as e:
logger.warning(f"Error parsing report file {report_file}: {e}")
return findings
def _parse_stdout_output(self, stdout: str, stderr: str, workspace: Path) -> List[ModuleFinding]:
"""Parse Garak stdout output"""
findings = []
try:
# Look for vulnerability indicators in output
lines = stdout.split('\n') + stderr.split('\n')
for line in lines:
if any(indicator in line.lower() for indicator in [
"vulnerability", "injection", "jailbreak", "bias", "harmful"
]):
# Create a basic finding from the output line
finding = self._create_basic_finding(line, workspace)
if finding:
findings.append(finding)
except Exception as e:
logger.warning(f"Error parsing Garak stdout: {e}")
return findings
def _create_garak_finding(self, data: Dict[str, Any], workspace: Path, report_file: Path) -> ModuleFinding:
"""Create finding from Garak report data"""
try:
# Extract information from Garak data structure
probe_name = data.get("probe", "unknown")
detector_name = data.get("detector", "unknown")
passed = data.get("passed", True)
prompt = data.get("prompt", "")[:500] # Limit length
response = data.get("response", "")[:500] # Limit length
# Only create findings for failed tests (vulnerabilities found)
if passed:
return None
# Determine vulnerability type and severity
vulnerability_type = self._get_vulnerability_type(probe_name, detector_name)
severity = self._get_vulnerability_severity(vulnerability_type, probe_name)
# Create relative path
try:
rel_path = report_file.relative_to(workspace)
file_path = str(rel_path)
except ValueError:
file_path = str(report_file)
finding = self.create_finding(
title=f"AI Security Issue: {vulnerability_type}",
description=f"Garak detected a {vulnerability_type} vulnerability using probe '{probe_name}' and detector '{detector_name}'",
severity=severity,
category=self._get_ai_security_category(vulnerability_type),
file_path=file_path,
recommendation=self._get_ai_security_recommendation(vulnerability_type, probe_name),
metadata={
"probe_name": probe_name,
"detector_name": detector_name,
"vulnerability_type": vulnerability_type,
"prompt_preview": prompt,
"response_preview": response,
"passed": passed,
"fuzzer": "garak"
}
)
return finding
except Exception as e:
logger.warning(f"Error creating Garak finding: {e}")
return None
def _create_basic_finding(self, line: str, workspace: Path) -> ModuleFinding:
"""Create basic finding from output line"""
try:
# Extract basic information from line
vulnerability_type = "ai_security_issue"
if "injection" in line.lower():
vulnerability_type = "prompt_injection"
elif "jailbreak" in line.lower():
vulnerability_type = "jailbreak_attempt"
elif "bias" in line.lower():
vulnerability_type = "bias_detection"
finding = self.create_finding(
title=f"AI Security Detection: {vulnerability_type.replace('_', ' ').title()}",
description=f"Garak detected potential AI security issue: {line.strip()}",
severity="medium",
category=self._get_ai_security_category(vulnerability_type),
file_path=None,
recommendation=self._get_ai_security_recommendation(vulnerability_type, "general"),
metadata={
"vulnerability_type": vulnerability_type,
"detection_line": line.strip(),
"fuzzer": "garak"
}
)
return finding
except Exception as e:
logger.warning(f"Error creating basic finding: {e}")
return None
def _get_vulnerability_type(self, probe_name: str, detector_name: str) -> str:
"""Determine vulnerability type from probe and detector names"""
probe_lower = probe_name.lower()
detector_lower = detector_name.lower()
if "inject" in probe_lower or "inject" in detector_lower:
return "prompt_injection"
elif "jailbreak" in probe_lower or "dan" in probe_lower:
return "jailbreak_attempt"
elif "bias" in probe_lower or "bias" in detector_lower:
return "bias_detection"
elif "malware" in probe_lower or "harmful" in detector_lower:
return "harmful_content_generation"
elif "encoding" in probe_lower:
return "encoding_vulnerability"
elif "leak" in probe_lower:
return "data_leakage"
else:
return "ai_security_vulnerability"
def _get_vulnerability_severity(self, vulnerability_type: str, probe_name: str) -> str:
"""Determine severity based on vulnerability type"""
if vulnerability_type in ["prompt_injection", "jailbreak_attempt"]:
return "high"
elif vulnerability_type in ["harmful_content_generation", "data_leakage"]:
return "high"
elif vulnerability_type in ["bias_detection", "encoding_vulnerability"]:
return "medium"
else:
return "medium"
def _get_ai_security_category(self, vulnerability_type: str) -> str:
"""Get category for AI security vulnerability"""
if "injection" in vulnerability_type:
return "prompt_injection"
elif "jailbreak" in vulnerability_type:
return "jailbreak_attack"
elif "bias" in vulnerability_type:
return "algorithmic_bias"
elif "harmful" in vulnerability_type or "malware" in vulnerability_type:
return "harmful_content"
elif "leak" in vulnerability_type:
return "data_leakage"
elif "encoding" in vulnerability_type:
return "input_manipulation"
else:
return "ai_security"
def _get_ai_security_recommendation(self, vulnerability_type: str, probe_name: str) -> str:
"""Get recommendation for AI security vulnerability"""
if "injection" in vulnerability_type:
return "Implement robust input validation, prompt sanitization, and use structured prompts to prevent injection attacks. Consider implementing content filtering and output validation."
elif "jailbreak" in vulnerability_type:
return "Strengthen model alignment and safety measures. Implement content filtering, use constitutional AI techniques, and add safety classifiers for output validation."
elif "bias" in vulnerability_type:
return "Review training data for bias, implement fairness constraints, use debiasing techniques, and conduct regular bias audits across different demographic groups."
elif "harmful" in vulnerability_type:
return "Implement strict content policies, use safety classifiers, add human oversight for sensitive outputs, and refuse to generate harmful content."
elif "leak" in vulnerability_type:
return "Review data handling practices, implement data anonymization, use differential privacy techniques, and audit model responses for sensitive information disclosure."
elif "encoding" in vulnerability_type:
return "Normalize and validate all input encodings, implement proper character filtering, and use encoding-aware input processing."
else:
return f"Address the {vulnerability_type} vulnerability by implementing appropriate AI safety measures, input validation, and output monitoring."
def _create_summary(self, findings: List[ModuleFinding]) -> Dict[str, Any]:
"""Create analysis summary"""
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
category_counts = {}
vulnerability_counts = {}
probe_counts = {}
for finding in findings:
# Count by severity
severity_counts[finding.severity] += 1
# Count by category
category = finding.category
category_counts[category] = category_counts.get(category, 0) + 1
# Count by vulnerability type
vuln_type = finding.metadata.get("vulnerability_type", "unknown")
vulnerability_counts[vuln_type] = vulnerability_counts.get(vuln_type, 0) + 1
# Count by probe
probe = finding.metadata.get("probe_name", "unknown")
probe_counts[probe] = probe_counts.get(probe, 0) + 1
return {
"total_findings": len(findings),
"severity_counts": severity_counts,
"category_counts": category_counts,
"vulnerability_counts": vulnerability_counts,
"probe_counts": probe_counts,
"ai_security_issues": len(findings),
"high_risk_vulnerabilities": severity_counts.get("high", 0) + severity_counts.get("critical", 0)
}
@@ -0,0 +1,14 @@
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
from .security_analyzer import SecurityAnalyzer
__all__ = ["SecurityAnalyzer"]
@@ -0,0 +1,368 @@
"""
Security Analyzer Module - Analyzes code for security vulnerabilities
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import logging
import re
from pathlib import Path
from typing import Dict, Any, List, Optional
try:
from toolbox.modules.base import BaseModule, ModuleMetadata, ModuleResult, ModuleFinding
except ImportError:
try:
from modules.base import BaseModule, ModuleMetadata, ModuleResult, ModuleFinding
except ImportError:
from src.toolbox.modules.base import BaseModule, ModuleMetadata, ModuleResult, ModuleFinding
logger = logging.getLogger(__name__)
class SecurityAnalyzer(BaseModule):
"""
Analyzes source code for common security vulnerabilities.
This module:
- Detects hardcoded secrets and credentials
- Identifies dangerous function calls
- Finds SQL injection vulnerabilities
- Detects insecure configurations
"""
def get_metadata(self) -> ModuleMetadata:
"""Get module metadata"""
return ModuleMetadata(
name="security_analyzer",
version="1.0.0",
description="Analyzes code for security vulnerabilities",
author="FuzzForge Team",
category="analyzer",
tags=["security", "vulnerabilities", "static-analysis"],
input_schema={
"file_extensions": {
"type": "array",
"items": {"type": "string"},
"description": "File extensions to analyze",
"default": [".py", ".js", ".java", ".php", ".rb", ".go"]
},
"check_secrets": {
"type": "boolean",
"description": "Check for hardcoded secrets",
"default": True
},
"check_sql": {
"type": "boolean",
"description": "Check for SQL injection risks",
"default": True
},
"check_dangerous_functions": {
"type": "boolean",
"description": "Check for dangerous function calls",
"default": True
}
},
output_schema={
"findings": {
"type": "array",
"description": "List of security findings"
}
},
requires_workspace=True
)
def validate_config(self, config: Dict[str, Any]) -> bool:
"""Validate module configuration"""
extensions = config.get("file_extensions", [])
if not isinstance(extensions, list):
raise ValueError("file_extensions must be a list")
return True
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
"""
Execute the security analysis module.
Args:
config: Module configuration
workspace: Path to the workspace directory
Returns:
ModuleResult with security findings
"""
self.start_timer()
self.validate_workspace(workspace)
self.validate_config(config)
findings = []
files_analyzed = 0
# Get configuration
file_extensions = config.get("file_extensions", [".py", ".js", ".java", ".php", ".rb", ".go"])
check_secrets = config.get("check_secrets", True)
check_sql = config.get("check_sql", True)
check_dangerous = config.get("check_dangerous_functions", True)
logger.info(f"Analyzing files with extensions: {file_extensions}")
try:
# Analyze each file
for ext in file_extensions:
for file_path in workspace.rglob(f"*{ext}"):
if not file_path.is_file():
continue
files_analyzed += 1
relative_path = file_path.relative_to(workspace)
try:
content = file_path.read_text(encoding='utf-8', errors='ignore')
lines = content.splitlines()
# Check for secrets
if check_secrets:
secret_findings = self._check_hardcoded_secrets(
content, lines, relative_path
)
findings.extend(secret_findings)
# Check for SQL injection
if check_sql and ext in [".py", ".php", ".java", ".js"]:
sql_findings = self._check_sql_injection(
content, lines, relative_path
)
findings.extend(sql_findings)
# Check for dangerous functions
if check_dangerous:
dangerous_findings = self._check_dangerous_functions(
content, lines, relative_path, ext
)
findings.extend(dangerous_findings)
except Exception as e:
logger.error(f"Error analyzing file {relative_path}: {e}")
# Create summary
summary = {
"files_analyzed": files_analyzed,
"total_findings": len(findings),
"extensions_scanned": file_extensions
}
return self.create_result(
findings=findings,
status="success" if files_analyzed > 0 else "partial",
summary=summary,
metadata={
"workspace": str(workspace),
"config": config
}
)
except Exception as e:
logger.error(f"Security analyzer failed: {e}")
return self.create_result(
findings=findings,
status="failed",
error=str(e)
)
def _check_hardcoded_secrets(
self, content: str, lines: List[str], file_path: Path
) -> List[ModuleFinding]:
"""
Check for hardcoded secrets in code.
Args:
content: File content
lines: File lines
file_path: Relative file path
Returns:
List of findings
"""
findings = []
# Patterns for secrets
secret_patterns = [
(r'api[_-]?key\s*=\s*["\']([^"\']{20,})["\']', 'API Key'),
(r'api[_-]?secret\s*=\s*["\']([^"\']{20,})["\']', 'API Secret'),
(r'password\s*=\s*["\']([^"\']+)["\']', 'Hardcoded Password'),
(r'token\s*=\s*["\']([^"\']{20,})["\']', 'Authentication Token'),
(r'aws[_-]?access[_-]?key\s*=\s*["\']([^"\']+)["\']', 'AWS Access Key'),
(r'aws[_-]?secret[_-]?key\s*=\s*["\']([^"\']+)["\']', 'AWS Secret Key'),
(r'private[_-]?key\s*=\s*["\']([^"\']+)["\']', 'Private Key'),
(r'["\']([A-Za-z0-9]{32,})["\']', 'Potential Secret Hash'),
(r'Bearer\s+([A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+)', 'JWT Token'),
]
for pattern, secret_type in secret_patterns:
for match in re.finditer(pattern, content, re.IGNORECASE):
# Find line number
line_num = content[:match.start()].count('\n') + 1
line_content = lines[line_num - 1] if line_num <= len(lines) else ""
# Skip common false positives
if self._is_false_positive_secret(match.group(0)):
continue
findings.append(self.create_finding(
title=f"Hardcoded {secret_type} detected",
description=f"Found potential hardcoded {secret_type} in {file_path}",
severity="high" if "key" in secret_type.lower() else "medium",
category="hardcoded_secret",
file_path=str(file_path),
line_start=line_num,
code_snippet=line_content.strip()[:100],
recommendation=f"Remove hardcoded {secret_type} and use environment variables or secure vault",
metadata={"secret_type": secret_type}
))
return findings
def _check_sql_injection(
self, content: str, lines: List[str], file_path: Path
) -> List[ModuleFinding]:
"""
Check for potential SQL injection vulnerabilities.
Args:
content: File content
lines: File lines
file_path: Relative file path
Returns:
List of findings
"""
findings = []
# SQL injection patterns
sql_patterns = [
(r'(SELECT|INSERT|UPDATE|DELETE).*\+\s*[\'"]?\s*\+?\s*\w+', 'String concatenation in SQL'),
(r'(SELECT|INSERT|UPDATE|DELETE).*%\s*[\'"]?\s*%?\s*\w+', 'String formatting in SQL'),
(r'f[\'"].*?(SELECT|INSERT|UPDATE|DELETE).*?\{.*?\}', 'F-string in SQL query'),
(r'query\s*=.*?\+', 'Dynamic query building'),
(r'execute\s*\(.*?\+.*?\)', 'Dynamic execute statement'),
]
for pattern, vuln_type in sql_patterns:
for match in re.finditer(pattern, content, re.IGNORECASE):
line_num = content[:match.start()].count('\n') + 1
line_content = lines[line_num - 1] if line_num <= len(lines) else ""
findings.append(self.create_finding(
title=f"Potential SQL Injection: {vuln_type}",
description=f"Detected potential SQL injection vulnerability via {vuln_type}",
severity="high",
category="sql_injection",
file_path=str(file_path),
line_start=line_num,
code_snippet=line_content.strip()[:100],
recommendation="Use parameterized queries or prepared statements instead",
metadata={"vulnerability_type": vuln_type}
))
return findings
def _check_dangerous_functions(
self, content: str, lines: List[str], file_path: Path, ext: str
) -> List[ModuleFinding]:
"""
Check for dangerous function calls.
Args:
content: File content
lines: File lines
file_path: Relative file path
ext: File extension
Returns:
List of findings
"""
findings = []
# Language-specific dangerous functions
dangerous_functions = {
".py": [
(r'eval\s*\(', 'eval()', 'Arbitrary code execution'),
(r'exec\s*\(', 'exec()', 'Arbitrary code execution'),
(r'os\.system\s*\(', 'os.system()', 'Command injection risk'),
(r'subprocess\.call\s*\(.*shell=True', 'subprocess with shell=True', 'Command injection risk'),
(r'pickle\.loads?\s*\(', 'pickle.load()', 'Deserialization vulnerability'),
],
".js": [
(r'eval\s*\(', 'eval()', 'Arbitrary code execution'),
(r'new\s+Function\s*\(', 'new Function()', 'Arbitrary code execution'),
(r'innerHTML\s*=', 'innerHTML', 'XSS vulnerability'),
(r'document\.write\s*\(', 'document.write()', 'XSS vulnerability'),
],
".php": [
(r'eval\s*\(', 'eval()', 'Arbitrary code execution'),
(r'exec\s*\(', 'exec()', 'Command execution'),
(r'system\s*\(', 'system()', 'Command execution'),
(r'shell_exec\s*\(', 'shell_exec()', 'Command execution'),
(r'\$_GET\[', 'Direct $_GET usage', 'Input validation missing'),
(r'\$_POST\[', 'Direct $_POST usage', 'Input validation missing'),
]
}
if ext in dangerous_functions:
for pattern, func_name, risk_type in dangerous_functions[ext]:
for match in re.finditer(pattern, content):
line_num = content[:match.start()].count('\n') + 1
line_content = lines[line_num - 1] if line_num <= len(lines) else ""
findings.append(self.create_finding(
title=f"Dangerous function: {func_name}",
description=f"Use of potentially dangerous function {func_name}: {risk_type}",
severity="medium",
category="dangerous_function",
file_path=str(file_path),
line_start=line_num,
code_snippet=line_content.strip()[:100],
recommendation=f"Consider safer alternatives to {func_name}",
metadata={
"function": func_name,
"risk": risk_type
}
))
return findings
def _is_false_positive_secret(self, value: str) -> bool:
"""
Check if a potential secret is likely a false positive.
Args:
value: Potential secret value
Returns:
True if likely false positive
"""
false_positive_patterns = [
'example',
'test',
'demo',
'sample',
'dummy',
'placeholder',
'xxx',
'123',
'change',
'your',
'here'
]
value_lower = value.lower()
return any(pattern in value_lower for pattern in false_positive_patterns)
+272
View File
@@ -0,0 +1,272 @@
"""
Base module interface for all FuzzForge modules
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Dict, Any, List, Optional
from pydantic import BaseModel, Field
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
class ModuleMetadata(BaseModel):
"""Metadata describing a module's capabilities and requirements"""
name: str = Field(..., description="Module name")
version: str = Field(..., description="Module version")
description: str = Field(..., description="Module description")
author: Optional[str] = Field(None, description="Module author")
category: str = Field(..., description="Module category (scanner, analyzer, reporter, etc.)")
tags: List[str] = Field(default_factory=list, description="Module tags")
input_schema: Dict[str, Any] = Field(default_factory=dict, description="Expected input schema")
output_schema: Dict[str, Any] = Field(default_factory=dict, description="Output schema")
requires_workspace: bool = Field(True, description="Whether module requires workspace access")
class ModuleFinding(BaseModel):
"""Individual finding from a module"""
id: str = Field(..., description="Unique finding ID")
title: str = Field(..., description="Finding title")
description: str = Field(..., description="Detailed description")
severity: str = Field(..., description="Severity level (info, low, medium, high, critical)")
category: str = Field(..., description="Finding category")
file_path: Optional[str] = Field(None, description="Affected file path relative to workspace")
line_start: Optional[int] = Field(None, description="Starting line number")
line_end: Optional[int] = Field(None, description="Ending line number")
code_snippet: Optional[str] = Field(None, description="Relevant code snippet")
recommendation: Optional[str] = Field(None, description="Remediation recommendation")
metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata")
class ModuleResult(BaseModel):
"""Standard result format from module execution"""
module: str = Field(..., description="Module name")
version: str = Field(..., description="Module version")
status: str = Field(default="success", description="Execution status (success, partial, failed)")
execution_time: float = Field(..., description="Execution time in seconds")
findings: List[ModuleFinding] = Field(default_factory=list, description="List of findings")
summary: Dict[str, Any] = Field(default_factory=dict, description="Summary statistics")
metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata")
error: Optional[str] = Field(None, description="Error message if failed")
sarif: Optional[Dict[str, Any]] = Field(None, description="SARIF report if generated by reporter module")
class BaseModule(ABC):
"""
Base interface for all security testing modules.
All modules must inherit from this class and implement the required methods.
Modules are designed to be stateless and reusable across different workflows.
"""
def __init__(self):
"""Initialize the module"""
self._metadata = self.get_metadata()
self._start_time = None
logger.info(f"Initialized module: {self._metadata.name} v{self._metadata.version}")
@abstractmethod
def get_metadata(self) -> ModuleMetadata:
"""
Get module metadata.
Returns:
ModuleMetadata object describing the module
"""
pass
@abstractmethod
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
"""
Execute the module with given configuration and workspace.
Args:
config: Module-specific configuration parameters
workspace: Path to the mounted workspace directory
Returns:
ModuleResult containing findings and metadata
"""
pass
@abstractmethod
def validate_config(self, config: Dict[str, Any]) -> bool:
"""
Validate the provided configuration against module requirements.
Args:
config: Configuration to validate
Returns:
True if configuration is valid, False otherwise
Raises:
ValueError: If configuration is invalid with details
"""
pass
def validate_workspace(self, workspace: Path) -> bool:
"""
Validate that the workspace exists and is accessible.
Args:
workspace: Path to the workspace
Returns:
True if workspace is valid
Raises:
ValueError: If workspace is invalid
"""
if not workspace.exists():
raise ValueError(f"Workspace does not exist: {workspace}")
if not workspace.is_dir():
raise ValueError(f"Workspace is not a directory: {workspace}")
return True
def create_finding(
self,
title: str,
description: str,
severity: str,
category: str,
**kwargs
) -> ModuleFinding:
"""
Helper method to create a standardized finding.
Args:
title: Finding title
description: Detailed description
severity: Severity level
category: Finding category
**kwargs: Additional finding fields
Returns:
ModuleFinding object
"""
import uuid
finding_id = str(uuid.uuid4())
return ModuleFinding(
id=finding_id,
title=title,
description=description,
severity=severity,
category=category,
**kwargs
)
def start_timer(self):
"""Start the execution timer"""
from time import time
self._start_time = time()
def get_execution_time(self) -> float:
"""Get the execution time in seconds"""
from time import time
if self._start_time is None:
return 0.0
return time() - self._start_time
def create_result(
self,
findings: List[ModuleFinding],
status: str = "success",
summary: Dict[str, Any] = None,
metadata: Dict[str, Any] = None,
error: str = None
) -> ModuleResult:
"""
Helper method to create a module result.
Args:
findings: List of findings
status: Execution status
summary: Summary statistics
metadata: Additional metadata
error: Error message if failed
Returns:
ModuleResult object
"""
return ModuleResult(
module=self._metadata.name,
version=self._metadata.version,
status=status,
execution_time=self.get_execution_time(),
findings=findings,
summary=summary or self._generate_summary(findings),
metadata=metadata or {},
error=error
)
def _generate_summary(self, findings: List[ModuleFinding]) -> Dict[str, Any]:
"""
Generate summary statistics from findings.
Args:
findings: List of findings
Returns:
Summary dictionary
"""
severity_counts = {
"info": 0,
"low": 0,
"medium": 0,
"high": 0,
"critical": 0
}
category_counts = {}
for finding in findings:
# Count by severity
if finding.severity in severity_counts:
severity_counts[finding.severity] += 1
# Count by category
if finding.category not in category_counts:
category_counts[finding.category] = 0
category_counts[finding.category] += 1
return {
"total_findings": len(findings),
"severity_counts": severity_counts,
"category_counts": category_counts,
"highest_severity": self._get_highest_severity(findings)
}
def _get_highest_severity(self, findings: List[ModuleFinding]) -> str:
"""
Get the highest severity from findings.
Args:
findings: List of findings
Returns:
Highest severity level
"""
severity_order = ["critical", "high", "medium", "low", "info"]
for severity in severity_order:
if any(f.severity == severity for f in findings):
return severity
return "none"
@@ -0,0 +1,37 @@
"""
CI/CD Security Modules
This package contains modules for CI/CD pipeline and workflow security testing.
Available modules:
- Zizmor: GitHub Actions workflow security analyzer
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
from typing import List, Type
from ..base import BaseModule
# Module registry for automatic discovery
CICD_SECURITY_MODULES: List[Type[BaseModule]] = []
def register_module(module_class: Type[BaseModule]):
"""Register a CI/CD security module"""
CICD_SECURITY_MODULES.append(module_class)
return module_class
def get_available_modules() -> List[Type[BaseModule]]:
"""Get all available CI/CD security modules"""
return CICD_SECURITY_MODULES.copy()
# Import modules to trigger registration
from .zizmor import ZizmorModule
@@ -0,0 +1,595 @@
"""
Zizmor CI/CD Security Module
This module uses Zizmor to analyze GitHub Actions workflows for security
vulnerabilities and misconfigurations.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import asyncio
import json
from pathlib import Path
from typing import Dict, Any, List
import subprocess
import logging
from ..base import BaseModule, ModuleMetadata, ModuleFinding, ModuleResult
from . import register_module
logger = logging.getLogger(__name__)
@register_module
class ZizmorModule(BaseModule):
"""Zizmor GitHub Actions security analysis module"""
def get_metadata(self) -> ModuleMetadata:
"""Get module metadata"""
return ModuleMetadata(
name="zizmor",
version="0.2.0",
description="GitHub Actions workflow security analyzer for detecting vulnerabilities and misconfigurations",
author="FuzzForge Team",
category="cicd_security",
tags=["github-actions", "cicd", "workflow", "security", "pipeline"],
input_schema={
"type": "object",
"properties": {
"workflow_dir": {
"type": "string",
"default": ".github/workflows",
"description": "Directory containing GitHub Actions workflows"
},
"workflow_files": {
"type": "array",
"items": {"type": "string"},
"description": "Specific workflow files to analyze"
},
"format": {
"type": "string",
"enum": ["json", "sarif", "pretty"],
"default": "json",
"description": "Output format"
},
"verbose": {
"type": "boolean",
"default": false,
"description": "Enable verbose output"
},
"offline": {
"type": "boolean",
"default": false,
"description": "Run in offline mode (no internet lookups)"
},
"no_online_audits": {
"type": "boolean",
"default": true,
"description": "Disable online audits for faster execution"
},
"pedantic": {
"type": "boolean",
"default": false,
"description": "Enable pedantic mode (more strict checking)"
},
"rules": {
"type": "array",
"items": {"type": "string"},
"description": "Specific rules to run"
},
"ignore_rules": {
"type": "array",
"items": {"type": "string"},
"description": "Rules to ignore"
},
"min_severity": {
"type": "string",
"enum": ["unknown", "informational", "low", "medium", "high"],
"default": "low",
"description": "Minimum severity level to report"
}
}
},
output_schema={
"type": "object",
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"rule_id": {"type": "string"},
"rule_name": {"type": "string"},
"severity": {"type": "string"},
"workflow_file": {"type": "string"},
"line_number": {"type": "integer"}
}
}
}
}
}
)
def validate_config(self, config: Dict[str, Any]) -> bool:
"""Validate configuration"""
workflow_dir = config.get("workflow_dir", ".github/workflows")
workflow_files = config.get("workflow_files", [])
if not workflow_dir and not workflow_files:
raise ValueError("Either workflow_dir or workflow_files must be specified")
return True
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
"""Execute Zizmor GitHub Actions security analysis"""
self.start_timer()
try:
# Validate inputs
self.validate_config(config)
self.validate_workspace(workspace)
logger.info("Running Zizmor GitHub Actions security analysis")
# Check Zizmor installation
await self._check_zizmor_installation()
# Find workflow files
workflow_files = self._find_workflow_files(workspace, config)
if not workflow_files:
logger.info("No GitHub Actions workflow files found")
return self.create_result(
findings=[],
status="success",
summary={"total_findings": 0, "workflows_scanned": 0}
)
# Run Zizmor analysis
findings = await self._run_zizmor_analysis(workflow_files, config, workspace)
# Create summary
summary = self._create_summary(findings, len(workflow_files))
logger.info(f"Zizmor found {len(findings)} CI/CD security issues")
return self.create_result(
findings=findings,
status="success",
summary=summary
)
except Exception as e:
logger.error(f"Zizmor module failed: {e}")
return self.create_result(
findings=[],
status="failed",
error=str(e)
)
async def _check_zizmor_installation(self):
"""Check if Zizmor is installed"""
try:
process = await asyncio.create_subprocess_exec(
"zizmor", "--version",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
raise RuntimeError("Zizmor not found. Install with: cargo install zizmor")
except FileNotFoundError:
raise RuntimeError("Zizmor not found. Install with: cargo install zizmor")
except Exception as e:
raise RuntimeError(f"Zizmor installation check failed: {e}")
def _find_workflow_files(self, workspace: Path, config: Dict[str, Any]) -> List[Path]:
"""Find GitHub Actions workflow files"""
workflow_files = []
# Check for specific files
specific_files = config.get("workflow_files", [])
for file_path in specific_files:
full_path = workspace / file_path
if full_path.exists():
workflow_files.append(full_path)
# Check workflow directory
if not workflow_files:
workflow_dir = workspace / config.get("workflow_dir", ".github/workflows")
if workflow_dir.exists():
# Find YAML files
for pattern in ["*.yml", "*.yaml"]:
workflow_files.extend(workflow_dir.glob(pattern))
return list(set(workflow_files)) # Remove duplicates
async def _run_zizmor_analysis(self, workflow_files: List[Path], config: Dict[str, Any], workspace: Path) -> List[ModuleFinding]:
"""Run Zizmor analysis on workflow files"""
findings = []
try:
for workflow_file in workflow_files:
file_findings = await self._analyze_workflow_file(workflow_file, config, workspace)
findings.extend(file_findings)
except Exception as e:
logger.warning(f"Error running Zizmor analysis: {e}")
return findings
async def _analyze_workflow_file(self, workflow_file: Path, config: Dict[str, Any], workspace: Path) -> List[ModuleFinding]:
"""Analyze a single workflow file with Zizmor"""
findings = []
try:
# Build Zizmor command
cmd = ["zizmor"]
# Add format
format_type = config.get("format", "json")
cmd.extend(["--format", format_type])
# Add minimum severity
min_severity = config.get("min_severity", "low")
cmd.extend(["--min-severity", min_severity])
# Add flags
if config.get("verbose", False):
cmd.append("--verbose")
if config.get("offline", False):
cmd.append("--offline")
if config.get("no_online_audits", True):
cmd.append("--no-online-audits")
if config.get("pedantic", False):
cmd.append("--pedantic")
# Add specific rules
rules = config.get("rules", [])
for rule in rules:
cmd.extend(["--rules", rule])
# Add ignore rules
ignore_rules = config.get("ignore_rules", [])
for rule in ignore_rules:
cmd.extend(["--ignore", rule])
# Add workflow file
cmd.append(str(workflow_file))
logger.debug(f"Running command: {' '.join(cmd)}")
# Run Zizmor
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=workspace
)
stdout, stderr = await process.communicate()
# Parse results (even if return code is non-zero, as it may contain findings)
if stdout.strip():
findings = self._parse_zizmor_output(
stdout.decode(), workflow_file, workspace, format_type
)
elif stderr.strip():
logger.warning(f"Zizmor analysis failed for {workflow_file}: {stderr.decode()}")
except Exception as e:
logger.warning(f"Error analyzing workflow file {workflow_file}: {e}")
return findings
def _parse_zizmor_output(self, output: str, workflow_file: Path, workspace: Path, format_type: str) -> List[ModuleFinding]:
"""Parse Zizmor output into findings"""
findings = []
try:
if format_type == "json":
findings = self._parse_json_output(output, workflow_file, workspace)
elif format_type == "sarif":
findings = self._parse_sarif_output(output, workflow_file, workspace)
else:
findings = self._parse_text_output(output, workflow_file, workspace)
except Exception as e:
logger.warning(f"Error parsing Zizmor output: {e}")
return findings
def _parse_json_output(self, output: str, workflow_file: Path, workspace: Path) -> List[ModuleFinding]:
"""Parse Zizmor JSON output"""
findings = []
try:
if not output.strip():
return findings
data = json.loads(output)
# Handle different JSON structures
if isinstance(data, dict):
# Single result
findings.extend(self._process_zizmor_result(data, workflow_file, workspace))
elif isinstance(data, list):
# Multiple results
for result in data:
findings.extend(self._process_zizmor_result(result, workflow_file, workspace))
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse Zizmor JSON output: {e}")
return findings
def _parse_sarif_output(self, output: str, workflow_file: Path, workspace: Path) -> List[ModuleFinding]:
"""Parse Zizmor SARIF output"""
findings = []
try:
data = json.loads(output)
runs = data.get("runs", [])
for run in runs:
results = run.get("results", [])
for result in results:
finding = self._create_sarif_finding(result, workflow_file, workspace)
if finding:
findings.append(finding)
except Exception as e:
logger.warning(f"Error parsing SARIF output: {e}")
return findings
def _parse_text_output(self, output: str, workflow_file: Path, workspace: Path) -> List[ModuleFinding]:
"""Parse Zizmor text output"""
findings = []
try:
lines = output.strip().split('\n')
for line in lines:
if line.strip() and not line.startswith('#'):
# Create basic finding from text line
finding = self._create_text_finding(line, workflow_file, workspace)
if finding:
findings.append(finding)
except Exception as e:
logger.warning(f"Error parsing text output: {e}")
return findings
def _process_zizmor_result(self, result: Dict[str, Any], workflow_file: Path, workspace: Path) -> List[ModuleFinding]:
"""Process a single Zizmor result"""
findings = []
try:
# Extract rule information
rule_id = result.get("rule", {}).get("id", "unknown")
rule_name = result.get("rule", {}).get("desc", rule_id)
severity = result.get("severity", "medium")
message = result.get("message", "")
# Extract location information
locations = result.get("locations", [])
if not locations:
# Create finding without specific location
finding = self._create_zizmor_finding(
rule_id, rule_name, severity, message, workflow_file, workspace
)
if finding:
findings.append(finding)
else:
# Create finding for each location
for location in locations:
line_number = location.get("line", 0)
column = location.get("column", 0)
finding = self._create_zizmor_finding(
rule_id, rule_name, severity, message, workflow_file, workspace,
line_number, column
)
if finding:
findings.append(finding)
except Exception as e:
logger.warning(f"Error processing Zizmor result: {e}")
return findings
def _create_zizmor_finding(self, rule_id: str, rule_name: str, severity: str, message: str,
workflow_file: Path, workspace: Path, line_number: int = None, column: int = None) -> ModuleFinding:
"""Create finding from Zizmor analysis"""
try:
# Map Zizmor severity to our standard levels
finding_severity = self._map_severity(severity)
# Create relative path
try:
rel_path = workflow_file.relative_to(workspace)
file_path = str(rel_path)
except ValueError:
file_path = str(workflow_file)
# Get category and recommendation
category = self._get_cicd_category(rule_id, rule_name)
recommendation = self._get_cicd_recommendation(rule_id, rule_name, message)
finding = self.create_finding(
title=f"CI/CD Security Issue: {rule_name}",
description=message or f"Zizmor detected a security issue: {rule_name}",
severity=finding_severity,
category=category,
file_path=file_path,
line_start=line_number if line_number else None,
recommendation=recommendation,
metadata={
"rule_id": rule_id,
"rule_name": rule_name,
"zizmor_severity": severity,
"workflow_file": str(workflow_file.name),
"line_number": line_number,
"column": column,
"tool": "zizmor"
}
)
return finding
except Exception as e:
logger.warning(f"Error creating Zizmor finding: {e}")
return None
def _create_sarif_finding(self, result: Dict[str, Any], workflow_file: Path, workspace: Path) -> ModuleFinding:
"""Create finding from SARIF result"""
try:
rule_id = result.get("ruleId", "unknown")
message = result.get("message", {}).get("text", "")
severity = result.get("level", "warning")
# Extract location
locations = result.get("locations", [])
line_number = None
if locations:
physical_location = locations[0].get("physicalLocation", {})
region = physical_location.get("region", {})
line_number = region.get("startLine")
return self._create_zizmor_finding(
rule_id, rule_id, severity, message, workflow_file, workspace, line_number
)
except Exception as e:
logger.warning(f"Error creating SARIF finding: {e}")
return None
def _create_text_finding(self, line: str, workflow_file: Path, workspace: Path) -> ModuleFinding:
"""Create finding from text line"""
try:
try:
rel_path = workflow_file.relative_to(workspace)
file_path = str(rel_path)
except ValueError:
file_path = str(workflow_file)
finding = self.create_finding(
title="CI/CD Security Issue",
description=line.strip(),
severity="medium",
category="workflow_security",
file_path=file_path,
recommendation="Review and address the workflow security issue identified by Zizmor.",
metadata={
"detection_line": line.strip(),
"workflow_file": str(workflow_file.name),
"tool": "zizmor"
}
)
return finding
except Exception as e:
logger.warning(f"Error creating text finding: {e}")
return None
def _map_severity(self, zizmor_severity: str) -> str:
"""Map Zizmor severity to our standard levels"""
severity_map = {
"high": "high",
"medium": "medium",
"low": "low",
"informational": "info",
"unknown": "low",
"error": "high",
"warning": "medium",
"note": "low"
}
return severity_map.get(zizmor_severity.lower(), "medium")
def _get_cicd_category(self, rule_id: str, rule_name: str) -> str:
"""Get category for CI/CD security issue"""
rule_lower = f"{rule_id} {rule_name}".lower()
if any(term in rule_lower for term in ["secret", "token", "credential", "password"]):
return "secret_exposure"
elif any(term in rule_lower for term in ["permission", "access", "privilege"]):
return "permission_escalation"
elif any(term in rule_lower for term in ["injection", "command", "script"]):
return "code_injection"
elif any(term in rule_lower for term in ["artifact", "cache", "upload"]):
return "artifact_security"
elif any(term in rule_lower for term in ["environment", "env", "variable"]):
return "environment_security"
elif any(term in rule_lower for term in ["network", "external", "download"]):
return "network_security"
else:
return "workflow_security"
def _get_cicd_recommendation(self, rule_id: str, rule_name: str, message: str) -> str:
"""Get recommendation for CI/CD security issue"""
rule_lower = f"{rule_id} {rule_name}".lower()
if "secret" in rule_lower or "token" in rule_lower:
return "Store secrets securely using GitHub Secrets or environment variables. Never hardcode credentials in workflow files."
elif "permission" in rule_lower:
return "Follow the principle of least privilege. Grant only necessary permissions and use specific permission scopes."
elif "injection" in rule_lower:
return "Avoid using user input directly in shell commands. Use proper escaping, validation, or structured approaches."
elif "artifact" in rule_lower:
return "Secure artifact handling by validating checksums, using signed artifacts, and restricting artifact access."
elif "environment" in rule_lower:
return "Protect environment variables and avoid exposing sensitive information in logs or outputs."
elif "network" in rule_lower:
return "Use HTTPS for external connections, validate certificates, and avoid downloading from untrusted sources."
elif message:
return f"Address the identified issue: {message}"
else:
return f"Review and fix the workflow security issue: {rule_name}"
def _create_summary(self, findings: List[ModuleFinding], workflows_count: int) -> Dict[str, Any]:
"""Create analysis summary"""
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
category_counts = {}
rule_counts = {}
workflow_counts = {}
for finding in findings:
# Count by severity
severity_counts[finding.severity] += 1
# Count by category
category = finding.category
category_counts[category] = category_counts.get(category, 0) + 1
# Count by rule
rule_id = finding.metadata.get("rule_id", "unknown")
rule_counts[rule_id] = rule_counts.get(rule_id, 0) + 1
# Count by workflow
workflow = finding.metadata.get("workflow_file", "unknown")
workflow_counts[workflow] = workflow_counts.get(workflow, 0) + 1
return {
"total_findings": len(findings),
"workflows_scanned": workflows_count,
"severity_counts": severity_counts,
"category_counts": category_counts,
"top_rules": dict(sorted(rule_counts.items(), key=lambda x: x[1], reverse=True)[:10]),
"workflows_with_issues": len(workflow_counts),
"workflow_issue_counts": dict(sorted(workflow_counts.items(), key=lambda x: x[1], reverse=True)[:10])
}
@@ -0,0 +1,49 @@
"""
Fuzzing Modules
This package contains modules for various fuzzing techniques and tools.
Available modules:
- LibFuzzer: LLVM's coverage-guided fuzzing engine
- AFL++: Advanced American Fuzzy Lop with modern features
- AFL-RS: Rust-based AFL implementation
- Atheris: Python fuzzing engine for finding bugs in Python code
- Cargo Fuzz: Rust fuzzing integration with libFuzzer
- Go-Fuzz: Coverage-guided fuzzing for Go packages
- OSS-Fuzz: Google's continuous fuzzing for open source
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
from typing import List, Type
from ..base import BaseModule
# Module registry for automatic discovery
FUZZING_MODULES: List[Type[BaseModule]] = []
def register_module(module_class: Type[BaseModule]):
"""Register a fuzzing module"""
FUZZING_MODULES.append(module_class)
return module_class
def get_available_modules() -> List[Type[BaseModule]]:
"""Get all available fuzzing modules"""
return FUZZING_MODULES.copy()
# Import modules to trigger registration
from .libfuzzer import LibFuzzerModule
from .aflplusplus import AFLPlusPlusModule
from .aflrs import AFLRSModule
from .atheris import AtherisModule
from .cargo_fuzz import CargoFuzzModule
from .go_fuzz import GoFuzzModule
from .oss_fuzz import OSSFuzzModule
@@ -0,0 +1,734 @@
"""
AFL++ Fuzzing Module
This module uses AFL++ (Advanced American Fuzzy Lop) for coverage-guided
fuzzing with modern features and optimizations.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import asyncio
import json
import os
from pathlib import Path
from typing import Dict, Any, List
import subprocess
import logging
import re
from ..base import BaseModule, ModuleMetadata, ModuleFinding, ModuleResult
from . import register_module
logger = logging.getLogger(__name__)
@register_module
class AFLPlusPlusModule(BaseModule):
"""AFL++ advanced fuzzing module"""
def get_metadata(self) -> ModuleMetadata:
"""Get module metadata"""
return ModuleMetadata(
name="aflplusplus",
version="4.09c",
description="Advanced American Fuzzy Lop with modern features for coverage-guided fuzzing",
author="FuzzForge Team",
category="fuzzing",
tags=["coverage-guided", "american-fuzzy-lop", "advanced", "mutation", "instrumentation"],
input_schema={
"type": "object",
"properties": {
"target_binary": {
"type": "string",
"description": "Path to the target binary (compiled with afl-gcc/afl-clang)"
},
"input_dir": {
"type": "string",
"description": "Directory containing seed input files"
},
"output_dir": {
"type": "string",
"default": "afl_output",
"description": "Output directory for AFL++ results"
},
"dictionary": {
"type": "string",
"description": "Dictionary file for fuzzing keywords"
},
"timeout": {
"type": "integer",
"default": 1000,
"description": "Timeout for each execution (ms)"
},
"memory_limit": {
"type": "integer",
"default": 50,
"description": "Memory limit for child process (MB)"
},
"skip_deterministic": {
"type": "boolean",
"default": false,
"description": "Skip deterministic mutations"
},
"no_arith": {
"type": "boolean",
"default": false,
"description": "Skip arithmetic mutations"
},
"shuffle_queue": {
"type": "boolean",
"default": false,
"description": "Shuffle queue entries"
},
"max_total_time": {
"type": "integer",
"default": 3600,
"description": "Maximum total fuzzing time (seconds)"
},
"power_schedule": {
"type": "string",
"enum": ["explore", "fast", "coe", "lin", "quad", "exploit", "rare"],
"default": "fast",
"description": "Power schedule algorithm"
},
"mutation_mode": {
"type": "string",
"enum": ["default", "old", "mopt"],
"default": "default",
"description": "Mutation mode to use"
},
"parallel_fuzzing": {
"type": "boolean",
"default": false,
"description": "Enable parallel fuzzing with multiple instances"
},
"fuzzer_instances": {
"type": "integer",
"default": 1,
"description": "Number of parallel fuzzer instances"
},
"master_instance": {
"type": "string",
"default": "master",
"description": "Name for master fuzzer instance"
},
"slave_prefix": {
"type": "string",
"default": "slave",
"description": "Prefix for slave fuzzer instances"
},
"hang_timeout": {
"type": "integer",
"default": 1000,
"description": "Timeout for detecting hangs (ms)"
},
"crash_mode": {
"type": "boolean",
"default": false,
"description": "Run in crash exploration mode"
},
"target_args": {
"type": "array",
"items": {"type": "string"},
"description": "Arguments to pass to target binary"
},
"env_vars": {
"type": "object",
"description": "Environment variables to set"
},
"ignore_finds": {
"type": "boolean",
"default": false,
"description": "Ignore existing findings and start fresh"
},
"force_deterministic": {
"type": "boolean",
"default": false,
"description": "Force deterministic mutations"
}
}
},
output_schema={
"type": "object",
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"crash_id": {"type": "string"},
"crash_file": {"type": "string"},
"crash_type": {"type": "string"},
"signal": {"type": "string"}
}
}
}
}
}
)
def validate_config(self, config: Dict[str, Any]) -> bool:
"""Validate configuration"""
target_binary = config.get("target_binary")
if not target_binary:
raise ValueError("target_binary is required for AFL++")
input_dir = config.get("input_dir")
if not input_dir:
raise ValueError("input_dir is required for AFL++")
return True
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
"""Execute AFL++ fuzzing"""
self.start_timer()
try:
# Validate inputs
self.validate_config(config)
self.validate_workspace(workspace)
logger.info("Running AFL++ fuzzing campaign")
# Check prerequisites
await self._check_afl_prerequisites(workspace)
# Setup directories and files
target_binary, input_dir, output_dir = self._setup_afl_directories(config, workspace)
# Run AFL++ fuzzing
findings = await self._run_afl_fuzzing(target_binary, input_dir, output_dir, config, workspace)
# Create summary
summary = self._create_summary(findings, output_dir)
logger.info(f"AFL++ found {len(findings)} crashes")
return self.create_result(
findings=findings,
status="success",
summary=summary
)
except Exception as e:
logger.error(f"AFL++ module failed: {e}")
return self.create_result(
findings=[],
status="failed",
error=str(e)
)
async def _check_afl_prerequisites(self, workspace: Path):
"""Check AFL++ prerequisites and system setup"""
try:
# Check if afl-fuzz exists
process = await asyncio.create_subprocess_exec(
"which", "afl-fuzz",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
raise RuntimeError("afl-fuzz not found. Please install AFL++")
# Check core dump pattern (important for AFL)
try:
with open("/proc/sys/kernel/core_pattern", "r") as f:
core_pattern = f.read().strip()
if core_pattern != "core":
logger.warning(f"Core dump pattern is '{core_pattern}', AFL++ may not work optimally")
except Exception:
logger.warning("Could not check core dump pattern")
except Exception as e:
logger.warning(f"AFL++ prerequisite check failed: {e}")
def _setup_afl_directories(self, config: Dict[str, Any], workspace: Path):
"""Setup AFL++ directories and validate files"""
# Check target binary
target_binary = workspace / config["target_binary"]
if not target_binary.exists():
raise FileNotFoundError(f"Target binary not found: {target_binary}")
# Check input directory
input_dir = workspace / config["input_dir"]
if not input_dir.exists():
raise FileNotFoundError(f"Input directory not found: {input_dir}")
# Check if input directory has files
input_files = list(input_dir.glob("*"))
if not input_files:
raise ValueError(f"Input directory is empty: {input_dir}")
# Create output directory
output_dir = workspace / config.get("output_dir", "afl_output")
output_dir.mkdir(exist_ok=True)
return target_binary, input_dir, output_dir
async def _run_afl_fuzzing(self, target_binary: Path, input_dir: Path, output_dir: Path, config: Dict[str, Any], workspace: Path) -> List[ModuleFinding]:
"""Run AFL++ fuzzing"""
findings = []
try:
if config.get("parallel_fuzzing", False):
findings = await self._run_parallel_fuzzing(
target_binary, input_dir, output_dir, config, workspace
)
else:
findings = await self._run_single_fuzzing(
target_binary, input_dir, output_dir, config, workspace
)
except Exception as e:
logger.warning(f"Error running AFL++ fuzzing: {e}")
return findings
async def _run_single_fuzzing(self, target_binary: Path, input_dir: Path, output_dir: Path, config: Dict[str, Any], workspace: Path) -> List[ModuleFinding]:
"""Run single-instance AFL++ fuzzing"""
findings = []
try:
# Build AFL++ command
cmd = ["afl-fuzz"]
# Add input and output directories
cmd.extend(["-i", str(input_dir)])
cmd.extend(["-o", str(output_dir)])
# Add dictionary if specified
dictionary = config.get("dictionary")
if dictionary:
dict_path = workspace / dictionary
if dict_path.exists():
cmd.extend(["-x", str(dict_path)])
# Add timeout
timeout = config.get("timeout", 1000)
cmd.extend(["-t", str(timeout)])
# Add memory limit
memory_limit = config.get("memory_limit", 50)
cmd.extend(["-m", str(memory_limit)])
# Add power schedule
power_schedule = config.get("power_schedule", "fast")
cmd.extend(["-p", power_schedule])
# Add mutation options
if config.get("skip_deterministic", False):
cmd.append("-d")
if config.get("no_arith", False):
cmd.append("-a")
if config.get("shuffle_queue", False):
cmd.append("-Z")
# Add hang timeout
hang_timeout = config.get("hang_timeout", 1000)
cmd.extend(["-T", str(hang_timeout)])
# Add crash mode
if config.get("crash_mode", False):
cmd.append("-C")
# Add ignore finds
if config.get("ignore_finds", False):
cmd.append("-f")
# Add force deterministic
if config.get("force_deterministic", False):
cmd.append("-D")
# Add target binary and arguments
cmd.append("--")
cmd.append(str(target_binary))
target_args = config.get("target_args", [])
cmd.extend(target_args)
# Set up environment
env = os.environ.copy()
env_vars = config.get("env_vars", {})
env.update(env_vars)
# Set AFL environment variables
env["AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES"] = "1" # Avoid interactive prompts
env["AFL_SKIP_CPUFREQ"] = "1" # Skip CPU frequency checks
logger.debug(f"Running command: {' '.join(cmd)}")
# Run AFL++ with timeout
max_total_time = config.get("max_total_time", 3600)
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=workspace,
env=env
)
# Wait for specified time then terminate
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(), timeout=max_total_time
)
except asyncio.TimeoutError:
logger.info(f"AFL++ fuzzing timed out after {max_total_time} seconds")
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=10)
except asyncio.TimeoutError:
process.kill()
await process.wait()
# Parse results from output directory
findings = self._parse_afl_results(output_dir, workspace)
except Exception as e:
logger.warning(f"Error running AFL++ process: {e}")
except Exception as e:
logger.warning(f"Error in single fuzzing: {e}")
return findings
async def _run_parallel_fuzzing(self, target_binary: Path, input_dir: Path, output_dir: Path, config: Dict[str, Any], workspace: Path) -> List[ModuleFinding]:
"""Run parallel AFL++ fuzzing"""
findings = []
try:
fuzzer_instances = config.get("fuzzer_instances", 2)
master_name = config.get("master_instance", "master")
slave_prefix = config.get("slave_prefix", "slave")
processes = []
# Start master instance
master_cmd = await self._build_afl_command(
target_binary, input_dir, output_dir, config, workspace,
instance_name=master_name, is_master=True
)
master_process = await asyncio.create_subprocess_exec(
*master_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=workspace,
env=self._get_afl_env(config)
)
processes.append(master_process)
# Start slave instances
for i in range(1, fuzzer_instances):
slave_name = f"{slave_prefix}{i:02d}"
slave_cmd = await self._build_afl_command(
target_binary, input_dir, output_dir, config, workspace,
instance_name=slave_name, is_master=False
)
slave_process = await asyncio.create_subprocess_exec(
*slave_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=workspace,
env=self._get_afl_env(config)
)
processes.append(slave_process)
# Wait for specified time then terminate all
max_total_time = config.get("max_total_time", 3600)
try:
await asyncio.sleep(max_total_time)
finally:
# Terminate all processes
for process in processes:
if process.returncode is None:
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=10)
except asyncio.TimeoutError:
process.kill()
await process.wait()
# Parse results from output directory
findings = self._parse_afl_results(output_dir, workspace)
except Exception as e:
logger.warning(f"Error in parallel fuzzing: {e}")
return findings
async def _build_afl_command(self, target_binary: Path, input_dir: Path, output_dir: Path, config: Dict[str, Any], workspace: Path, instance_name: str, is_master: bool) -> List[str]:
"""Build AFL++ command for a fuzzer instance"""
cmd = ["afl-fuzz"]
# Add input and output directories
cmd.extend(["-i", str(input_dir)])
cmd.extend(["-o", str(output_dir)])
# Add instance name
if is_master:
cmd.extend(["-M", instance_name])
else:
cmd.extend(["-S", instance_name])
# Add other options (same as single fuzzing)
dictionary = config.get("dictionary")
if dictionary:
dict_path = workspace / dictionary
if dict_path.exists():
cmd.extend(["-x", str(dict_path)])
cmd.extend(["-t", str(config.get("timeout", 1000))])
cmd.extend(["-m", str(config.get("memory_limit", 50))])
cmd.extend(["-p", config.get("power_schedule", "fast")])
if config.get("skip_deterministic", False):
cmd.append("-d")
if config.get("no_arith", False):
cmd.append("-a")
# Add target
cmd.append("--")
cmd.append(str(target_binary))
cmd.extend(config.get("target_args", []))
return cmd
def _get_afl_env(self, config: Dict[str, Any]) -> Dict[str, str]:
"""Get environment variables for AFL++"""
env = os.environ.copy()
env.update(config.get("env_vars", {}))
env["AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES"] = "1"
env["AFL_SKIP_CPUFREQ"] = "1"
return env
def _parse_afl_results(self, output_dir: Path, workspace: Path) -> List[ModuleFinding]:
"""Parse AFL++ results from output directory"""
findings = []
try:
# Look for crashes directory
crashes_dirs = []
# Single instance
crashes_dir = output_dir / "crashes"
if crashes_dir.exists():
crashes_dirs.append(crashes_dir)
# Multiple instances
for instance_dir in output_dir.iterdir():
if instance_dir.is_dir():
instance_crashes = instance_dir / "crashes"
if instance_crashes.exists():
crashes_dirs.append(instance_crashes)
# Process crash files
for crashes_dir in crashes_dirs:
crash_files = [f for f in crashes_dir.iterdir() if f.is_file() and f.name.startswith("id:")]
for crash_file in crash_files:
finding = self._create_afl_crash_finding(crash_file, workspace)
if finding:
findings.append(finding)
except Exception as e:
logger.warning(f"Error parsing AFL++ results: {e}")
return findings
def _create_afl_crash_finding(self, crash_file: Path, workspace: Path) -> ModuleFinding:
"""Create finding from AFL++ crash file"""
try:
# Parse crash filename for information
filename = crash_file.name
crash_info = self._parse_afl_filename(filename)
# Try to read crash file (limited size)
crash_content = ""
try:
crash_data = crash_file.read_bytes()[:1000]
crash_content = crash_data.hex()[:200] # Hex representation, limited
except Exception:
pass
# Determine severity based on signal
severity = self._get_crash_severity(crash_info.get("signal", ""))
# Create relative path
try:
rel_path = crash_file.relative_to(workspace)
file_path = str(rel_path)
except ValueError:
file_path = str(crash_file)
finding = self.create_finding(
title=f"AFL++ Crash: {crash_info.get('signal', 'Unknown')}",
description=f"AFL++ discovered a crash with signal {crash_info.get('signal', 'unknown')} in the target program",
severity=severity,
category=self._get_crash_category(crash_info.get("signal", "")),
file_path=file_path,
recommendation=self._get_afl_crash_recommendation(crash_info.get("signal", "")),
metadata={
"crash_id": crash_info.get("id", ""),
"signal": crash_info.get("signal", ""),
"src": crash_info.get("src", ""),
"crash_file": crash_file.name,
"crash_content_hex": crash_content,
"fuzzer": "afl++"
}
)
return finding
except Exception as e:
logger.warning(f"Error creating AFL++ crash finding: {e}")
return None
def _parse_afl_filename(self, filename: str) -> Dict[str, str]:
"""Parse AFL++ crash filename for information"""
info = {}
try:
# AFL++ crash filename format: id:XXXXXX,sig:XX,src:XXXXXX,op:XXX,rep:X
parts = filename.split(',')
for part in parts:
if ':' in part:
key, value = part.split(':', 1)
info[key] = value
except Exception:
pass
return info
def _get_crash_severity(self, signal: str) -> str:
"""Determine severity based on crash signal"""
if not signal:
return "medium"
signal_lower = signal.lower()
# Critical signals indicating memory corruption
if signal in ["11", "sigsegv", "segv"]: # Segmentation fault
return "critical"
elif signal in ["6", "sigabrt", "abrt"]: # Abort
return "high"
elif signal in ["4", "sigill", "ill"]: # Illegal instruction
return "high"
elif signal in ["8", "sigfpe", "fpe"]: # Floating point exception
return "medium"
elif signal in ["9", "sigkill", "kill"]: # Kill signal
return "medium"
else:
return "medium"
def _get_crash_category(self, signal: str) -> str:
"""Determine category based on crash signal"""
if not signal:
return "program_crash"
if signal in ["11", "sigsegv", "segv"]:
return "memory_corruption"
elif signal in ["6", "sigabrt", "abrt"]:
return "assertion_failure"
elif signal in ["4", "sigill", "ill"]:
return "illegal_instruction"
elif signal in ["8", "sigfpe", "fpe"]:
return "arithmetic_error"
else:
return "program_crash"
def _get_afl_crash_recommendation(self, signal: str) -> str:
"""Generate recommendation based on crash signal"""
if signal in ["11", "sigsegv", "segv"]:
return "Segmentation fault detected. Investigate memory access patterns, check for buffer overflows, null pointer dereferences, or use-after-free bugs."
elif signal in ["6", "sigabrt", "abrt"]:
return "Program abort detected. Check for assertion failures, memory allocation errors, or explicit abort() calls in the code."
elif signal in ["4", "sigill", "ill"]:
return "Illegal instruction detected. Check for code corruption, invalid function pointers, or architecture-specific instruction issues."
elif signal in ["8", "sigfpe", "fpe"]:
return "Floating point exception detected. Check for division by zero, arithmetic overflow, or invalid floating point operations."
else:
return f"Program crash with signal {signal} detected. Analyze the crash dump and input to identify the root cause."
def _create_summary(self, findings: List[ModuleFinding], output_dir: Path) -> Dict[str, Any]:
"""Create analysis summary"""
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
category_counts = {}
signal_counts = {}
for finding in findings:
# Count by severity
severity_counts[finding.severity] += 1
# Count by category
category = finding.category
category_counts[category] = category_counts.get(category, 0) + 1
# Count by signal
signal = finding.metadata.get("signal", "unknown")
signal_counts[signal] = signal_counts.get(signal, 0) + 1
# Try to read AFL++ statistics
stats = self._read_afl_stats(output_dir)
return {
"total_findings": len(findings),
"severity_counts": severity_counts,
"category_counts": category_counts,
"signal_counts": signal_counts,
"unique_crashes": len(set(f.metadata.get("crash_id", "") for f in findings)),
"afl_stats": stats
}
def _read_afl_stats(self, output_dir: Path) -> Dict[str, Any]:
"""Read AFL++ fuzzer statistics"""
stats = {}
try:
# Look for fuzzer_stats file in single or multiple instance setup
stats_files = []
# Single instance
single_stats = output_dir / "fuzzer_stats"
if single_stats.exists():
stats_files.append(single_stats)
# Multiple instances
for instance_dir in output_dir.iterdir():
if instance_dir.is_dir():
instance_stats = instance_dir / "fuzzer_stats"
if instance_stats.exists():
stats_files.append(instance_stats)
# Read first stats file found
if stats_files:
with open(stats_files[0], 'r') as f:
for line in f:
if ':' in line:
key, value = line.strip().split(':', 1)
stats[key.strip()] = value.strip()
except Exception as e:
logger.warning(f"Error reading AFL++ stats: {e}")
return stats
+678
View File
@@ -0,0 +1,678 @@
"""
AFL-RS Fuzzing Module
This module uses AFL-RS (AFL in Rust) for high-performance coverage-guided fuzzing
with modern Rust implementations and optimizations.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import asyncio
import json
import os
from pathlib import Path
from typing import Dict, Any, List
import subprocess
import logging
import re
from ..base import BaseModule, ModuleMetadata, ModuleFinding, ModuleResult
from . import register_module
logger = logging.getLogger(__name__)
@register_module
class AFLRSModule(BaseModule):
"""AFL-RS Rust-based fuzzing module"""
def get_metadata(self) -> ModuleMetadata:
"""Get module metadata"""
return ModuleMetadata(
name="aflrs",
version="0.2.0",
description="High-performance AFL implementation in Rust with modern fuzzing features",
author="FuzzForge Team",
category="fuzzing",
tags=["coverage-guided", "rust", "afl", "high-performance", "modern"],
input_schema={
"type": "object",
"properties": {
"target_binary": {
"type": "string",
"description": "Path to the target binary (compiled with AFL-RS instrumentation)"
},
"input_dir": {
"type": "string",
"description": "Directory containing seed input files"
},
"output_dir": {
"type": "string",
"default": "aflrs_output",
"description": "Output directory for AFL-RS results"
},
"dictionary": {
"type": "string",
"description": "Dictionary file for token-based mutations"
},
"timeout": {
"type": "integer",
"default": 1000,
"description": "Timeout for each execution (ms)"
},
"memory_limit": {
"type": "integer",
"default": 50,
"description": "Memory limit for target process (MB)"
},
"max_total_time": {
"type": "integer",
"default": 3600,
"description": "Maximum total fuzzing time (seconds)"
},
"cpu_cores": {
"type": "integer",
"default": 1,
"description": "Number of CPU cores to use"
},
"mutation_depth": {
"type": "integer",
"default": 4,
"description": "Maximum depth for cascaded mutations"
},
"skip_deterministic": {
"type": "boolean",
"default": false,
"description": "Skip deterministic mutations"
},
"power_schedule": {
"type": "string",
"enum": ["explore", "fast", "coe", "lin", "quad", "exploit", "rare", "mmopt", "seek"],
"default": "fast",
"description": "Power scheduling algorithm"
},
"custom_mutators": {
"type": "array",
"items": {"type": "string"},
"description": "Custom mutator libraries to load"
},
"cmplog": {
"type": "boolean",
"default": true,
"description": "Enable CmpLog for comparison logging"
},
"redqueen": {
"type": "boolean",
"default": true,
"description": "Enable RedQueen input-to-state correspondence"
},
"unicorn_mode": {
"type": "boolean",
"default": false,
"description": "Enable Unicorn mode for emulation"
},
"persistent_mode": {
"type": "boolean",
"default": false,
"description": "Enable persistent mode for faster execution"
},
"target_args": {
"type": "array",
"items": {"type": "string"},
"description": "Arguments to pass to target binary"
},
"env_vars": {
"type": "object",
"description": "Environment variables to set"
},
"ignore_timeouts": {
"type": "boolean",
"default": false,
"description": "Ignore timeout signals and continue fuzzing"
},
"ignore_crashes": {
"type": "boolean",
"default": false,
"description": "Ignore crashes and continue fuzzing"
},
"sync_dir": {
"type": "string",
"description": "Directory for syncing with other AFL instances"
},
"sync_id": {
"type": "string",
"description": "Fuzzer ID for syncing"
}
}
},
output_schema={
"type": "object",
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"crash_id": {"type": "string"},
"crash_file": {"type": "string"},
"signal": {"type": "string"},
"execution_time": {"type": "integer"}
}
}
}
}
}
)
def validate_config(self, config: Dict[str, Any]) -> bool:
"""Validate configuration"""
target_binary = config.get("target_binary")
if not target_binary:
raise ValueError("target_binary is required for AFL-RS")
input_dir = config.get("input_dir")
if not input_dir:
raise ValueError("input_dir is required for AFL-RS")
cpu_cores = config.get("cpu_cores", 1)
if cpu_cores < 1:
raise ValueError("cpu_cores must be at least 1")
return True
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
"""Execute AFL-RS fuzzing"""
self.start_timer()
try:
# Validate inputs
self.validate_config(config)
self.validate_workspace(workspace)
logger.info("Running AFL-RS fuzzing campaign")
# Check AFL-RS installation
await self._check_aflrs_installation()
# Setup directories and files
target_binary, input_dir, output_dir = self._setup_aflrs_directories(config, workspace)
# Run AFL-RS fuzzing
findings = await self._run_aflrs_fuzzing(target_binary, input_dir, output_dir, config, workspace)
# Create summary
summary = self._create_summary(findings, output_dir)
logger.info(f"AFL-RS found {len(findings)} crashes")
return self.create_result(
findings=findings,
status="success",
summary=summary
)
except Exception as e:
logger.error(f"AFL-RS module failed: {e}")
return self.create_result(
findings=[],
status="failed",
error=str(e)
)
async def _check_aflrs_installation(self):
"""Check if AFL-RS is installed and available"""
try:
# Check if aflrs is available (assuming aflrs binary)
process = await asyncio.create_subprocess_exec(
"which", "aflrs",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
# Try alternative AFL-RS command names
alt_commands = ["afl-fuzz-rs", "afl-rs", "cargo-afl"]
found = False
for cmd in alt_commands:
process = await asyncio.create_subprocess_exec(
"which", cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode == 0:
found = True
break
if not found:
raise RuntimeError("AFL-RS not found. Please install AFL-RS or ensure it's in PATH")
except Exception as e:
logger.warning(f"AFL-RS installation check failed: {e}")
def _setup_aflrs_directories(self, config: Dict[str, Any], workspace: Path):
"""Setup AFL-RS directories and validate files"""
# Check target binary
target_binary = workspace / config["target_binary"]
if not target_binary.exists():
raise FileNotFoundError(f"Target binary not found: {target_binary}")
# Check input directory
input_dir = workspace / config["input_dir"]
if not input_dir.exists():
raise FileNotFoundError(f"Input directory not found: {input_dir}")
# Validate input files exist
input_files = list(input_dir.glob("*"))
if not input_files:
raise ValueError(f"Input directory is empty: {input_dir}")
# Create output directory
output_dir = workspace / config.get("output_dir", "aflrs_output")
output_dir.mkdir(exist_ok=True)
return target_binary, input_dir, output_dir
async def _run_aflrs_fuzzing(self, target_binary: Path, input_dir: Path, output_dir: Path, config: Dict[str, Any], workspace: Path) -> List[ModuleFinding]:
"""Run AFL-RS fuzzing"""
findings = []
try:
# Build AFL-RS command
cmd = await self._build_aflrs_command(target_binary, input_dir, output_dir, config, workspace)
# Set up environment
env = self._setup_aflrs_environment(config)
logger.debug(f"Running command: {' '.join(cmd)}")
# Run AFL-RS with timeout
max_total_time = config.get("max_total_time", 3600)
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=workspace,
env=env
)
# Wait for specified time then terminate
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(), timeout=max_total_time
)
logger.info(f"AFL-RS completed after {max_total_time} seconds")
except asyncio.TimeoutError:
logger.info(f"AFL-RS fuzzing timed out after {max_total_time} seconds, terminating")
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=10)
except asyncio.TimeoutError:
process.kill()
await process.wait()
# Parse results
findings = self._parse_aflrs_results(output_dir, workspace)
except Exception as e:
logger.warning(f"Error running AFL-RS process: {e}")
except Exception as e:
logger.warning(f"Error in AFL-RS fuzzing: {e}")
return findings
async def _build_aflrs_command(self, target_binary: Path, input_dir: Path, output_dir: Path, config: Dict[str, Any], workspace: Path) -> List[str]:
"""Build AFL-RS command"""
# Try to determine the correct AFL-RS command
aflrs_cmd = "aflrs" # Default
# Try alternative command names
alt_commands = ["aflrs", "afl-fuzz-rs", "afl-rs"]
for cmd in alt_commands:
try:
process = await asyncio.create_subprocess_exec(
"which", cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode == 0:
aflrs_cmd = cmd
break
except Exception:
continue
cmd = [aflrs_cmd]
# Add input and output directories
cmd.extend(["-i", str(input_dir)])
cmd.extend(["-o", str(output_dir)])
# Add dictionary if specified
dictionary = config.get("dictionary")
if dictionary:
dict_path = workspace / dictionary
if dict_path.exists():
cmd.extend(["-x", str(dict_path)])
# Add timeout and memory limit
cmd.extend(["-t", str(config.get("timeout", 1000))])
cmd.extend(["-m", str(config.get("memory_limit", 50))])
# Add CPU cores
cpu_cores = config.get("cpu_cores", 1)
if cpu_cores > 1:
cmd.extend(["-j", str(cpu_cores)])
# Add mutation depth
mutation_depth = config.get("mutation_depth", 4)
cmd.extend(["-d", str(mutation_depth)])
# Add power schedule
power_schedule = config.get("power_schedule", "fast")
cmd.extend(["-p", power_schedule])
# Add skip deterministic
if config.get("skip_deterministic", False):
cmd.append("-D")
# Add custom mutators
custom_mutators = config.get("custom_mutators", [])
for mutator in custom_mutators:
cmd.extend(["-c", mutator])
# Add advanced features
if config.get("cmplog", True):
cmd.append("-l")
if config.get("redqueen", True):
cmd.append("-I")
if config.get("unicorn_mode", False):
cmd.append("-U")
if config.get("persistent_mode", False):
cmd.append("-P")
# Add ignore options
if config.get("ignore_timeouts", False):
cmd.append("-T")
if config.get("ignore_crashes", False):
cmd.append("-C")
# Add sync options
sync_dir = config.get("sync_dir")
if sync_dir:
cmd.extend(["-F", sync_dir])
sync_id = config.get("sync_id")
if sync_id:
cmd.extend(["-S", sync_id])
# Add target binary and arguments
cmd.append("--")
cmd.append(str(target_binary))
target_args = config.get("target_args", [])
cmd.extend(target_args)
return cmd
def _setup_aflrs_environment(self, config: Dict[str, Any]) -> Dict[str, str]:
"""Setup environment variables for AFL-RS"""
env = os.environ.copy()
# Add user-specified environment variables
env_vars = config.get("env_vars", {})
env.update(env_vars)
# Set AFL-RS specific environment variables
env["AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES"] = "1"
env["AFL_SKIP_CPUFREQ"] = "1"
# Enable advanced features if requested
if config.get("cmplog", True):
env["AFL_USE_CMPLOG"] = "1"
if config.get("redqueen", True):
env["AFL_USE_REDQUEEN"] = "1"
return env
def _parse_aflrs_results(self, output_dir: Path, workspace: Path) -> List[ModuleFinding]:
"""Parse AFL-RS results from output directory"""
findings = []
try:
# Look for crashes directory
crashes_dir = output_dir / "crashes"
if not crashes_dir.exists():
logger.info("No crashes directory found in AFL-RS output")
return findings
# Process crash files
crash_files = [f for f in crashes_dir.iterdir() if f.is_file() and not f.name.startswith(".")]
for crash_file in crash_files:
finding = self._create_aflrs_crash_finding(crash_file, workspace)
if finding:
findings.append(finding)
except Exception as e:
logger.warning(f"Error parsing AFL-RS results: {e}")
return findings
def _create_aflrs_crash_finding(self, crash_file: Path, workspace: Path) -> ModuleFinding:
"""Create finding from AFL-RS crash file"""
try:
# Parse crash filename
filename = crash_file.name
crash_info = self._parse_aflrs_filename(filename)
# Try to read crash file (limited size)
crash_content = ""
crash_size = 0
try:
crash_data = crash_file.read_bytes()
crash_size = len(crash_data)
# Store first 500 bytes as hex
crash_content = crash_data[:500].hex()
except Exception:
pass
# Determine severity based on signal or crash type
signal = crash_info.get("signal", "")
severity = self._get_crash_severity(signal)
# Create relative path
try:
rel_path = crash_file.relative_to(workspace)
file_path = str(rel_path)
except ValueError:
file_path = str(crash_file)
finding = self.create_finding(
title=f"AFL-RS Crash: {signal or 'Unknown Signal'}",
description=f"AFL-RS discovered a crash in the target program{' with signal ' + signal if signal else ''}",
severity=severity,
category=self._get_crash_category(signal),
file_path=file_path,
recommendation=self._get_crash_recommendation(signal),
metadata={
"crash_id": crash_info.get("id", ""),
"signal": signal,
"execution_time": crash_info.get("time", ""),
"crash_file": crash_file.name,
"crash_size": crash_size,
"crash_content_hex": crash_content,
"fuzzer": "aflrs"
}
)
return finding
except Exception as e:
logger.warning(f"Error creating AFL-RS crash finding: {e}")
return None
def _parse_aflrs_filename(self, filename: str) -> Dict[str, str]:
"""Parse AFL-RS crash filename for information"""
info = {}
try:
# AFL-RS may use similar format to AFL++
# Example: id_000000_sig_11_src_000000_time_12345_op_havoc_rep_128
parts = filename.replace("id:", "id_").replace("sig:", "sig_").replace("src:", "src_").replace("time:", "time_").replace("op:", "op_").replace("rep:", "rep_").split("_")
i = 0
while i < len(parts) - 1:
if parts[i] in ["id", "sig", "src", "time", "op", "rep"]:
info[parts[i]] = parts[i + 1]
i += 2
else:
i += 1
except Exception:
# Fallback: try to extract signal from filename
signal_match = re.search(r'sig[_:]?(\d+)', filename)
if signal_match:
info["signal"] = signal_match.group(1)
return info
def _get_crash_severity(self, signal: str) -> str:
"""Determine crash severity based on signal"""
if not signal:
return "medium"
try:
sig_num = int(signal)
except ValueError:
return "medium"
# Map common signals to severity
if sig_num == 11: # SIGSEGV
return "critical"
elif sig_num == 6: # SIGABRT
return "high"
elif sig_num == 4: # SIGILL
return "high"
elif sig_num == 8: # SIGFPE
return "medium"
elif sig_num == 9: # SIGKILL
return "medium"
else:
return "medium"
def _get_crash_category(self, signal: str) -> str:
"""Determine crash category based on signal"""
if not signal:
return "program_crash"
try:
sig_num = int(signal)
except ValueError:
return "program_crash"
if sig_num == 11: # SIGSEGV
return "memory_corruption"
elif sig_num == 6: # SIGABRT
return "assertion_failure"
elif sig_num == 4: # SIGILL
return "illegal_instruction"
elif sig_num == 8: # SIGFPE
return "arithmetic_error"
else:
return "program_crash"
def _get_crash_recommendation(self, signal: str) -> str:
"""Generate recommendation based on crash signal"""
if not signal:
return "Analyze the crash input to reproduce and debug the issue."
try:
sig_num = int(signal)
except ValueError:
return "Analyze the crash input to reproduce and debug the issue."
if sig_num == 11: # SIGSEGV
return "Segmentation fault detected. Check for buffer overflows, null pointer dereferences, use-after-free, or invalid memory access patterns."
elif sig_num == 6: # SIGABRT
return "Program abort detected. Check for assertion failures, memory corruption detected by allocator, or explicit abort calls."
elif sig_num == 4: # SIGILL
return "Illegal instruction detected. Check for code corruption, invalid function pointers, or architecture-specific issues."
elif sig_num == 8: # SIGFPE
return "Floating point exception detected. Check for division by zero, arithmetic overflow, or invalid floating point operations."
else:
return f"Program terminated with signal {signal}. Analyze the crash input and use debugging tools to identify the root cause."
def _create_summary(self, findings: List[ModuleFinding], output_dir: Path) -> Dict[str, Any]:
"""Create analysis summary"""
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
category_counts = {}
signal_counts = {}
for finding in findings:
# Count by severity
severity_counts[finding.severity] += 1
# Count by category
category = finding.category
category_counts[category] = category_counts.get(category, 0) + 1
# Count by signal
signal = finding.metadata.get("signal", "unknown")
signal_counts[signal] = signal_counts.get(signal, 0) + 1
# Try to read AFL-RS statistics
stats = self._read_aflrs_stats(output_dir)
return {
"total_findings": len(findings),
"severity_counts": severity_counts,
"category_counts": category_counts,
"signal_counts": signal_counts,
"unique_crashes": len(set(f.metadata.get("crash_id", "") for f in findings)),
"aflrs_stats": stats
}
def _read_aflrs_stats(self, output_dir: Path) -> Dict[str, Any]:
"""Read AFL-RS fuzzer statistics"""
stats = {}
try:
# Look for AFL-RS stats file
stats_file = output_dir / "fuzzer_stats"
if stats_file.exists():
with open(stats_file, 'r') as f:
for line in f:
if ':' in line:
key, value = line.strip().split(':', 1)
stats[key.strip()] = value.strip()
# Also look for AFL-RS specific files
plot_data = output_dir / "plot_data"
if plot_data.exists():
stats["plot_data_available"] = True
except Exception as e:
logger.warning(f"Error reading AFL-RS stats: {e}")
return stats
+774
View File
@@ -0,0 +1,774 @@
"""
Atheris Fuzzing Module
This module uses Atheris for fuzzing Python code to find bugs and security
vulnerabilities in Python applications and libraries.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import asyncio
import json
import os
import sys
from pathlib import Path
from typing import Dict, Any, List
import subprocess
import logging
import traceback
from ..base import BaseModule, ModuleMetadata, ModuleFinding, ModuleResult
from . import register_module
logger = logging.getLogger(__name__)
@register_module
class AtherisModule(BaseModule):
"""Atheris Python fuzzing module"""
def get_metadata(self) -> ModuleMetadata:
"""Get module metadata"""
return ModuleMetadata(
name="atheris",
version="2.3.0",
description="Coverage-guided Python fuzzing engine for finding bugs in Python code",
author="FuzzForge Team",
category="fuzzing",
tags=["python", "coverage-guided", "native", "sanitizers", "libfuzzer"],
input_schema={
"type": "object",
"properties": {
"target_script": {
"type": "string",
"description": "Path to the Python script containing the fuzz target function"
},
"target_function": {
"type": "string",
"default": "TestOneInput",
"description": "Name of the target function to fuzz"
},
"corpus_dir": {
"type": "string",
"description": "Directory containing initial corpus files"
},
"dict_file": {
"type": "string",
"description": "Dictionary file for fuzzing keywords"
},
"max_total_time": {
"type": "integer",
"default": 600,
"description": "Maximum total time to run fuzzing (seconds)"
},
"max_len": {
"type": "integer",
"default": 4096,
"description": "Maximum length of test input"
},
"timeout": {
"type": "integer",
"default": 25,
"description": "Timeout for individual test cases (seconds)"
},
"runs": {
"type": "integer",
"default": -1,
"description": "Number of individual test runs (-1 for unlimited)"
},
"jobs": {
"type": "integer",
"default": 1,
"description": "Number of fuzzing jobs to run in parallel"
},
"print_final_stats": {
"type": "boolean",
"default": true,
"description": "Print final statistics"
},
"print_pcs": {
"type": "boolean",
"default": false,
"description": "Print newly covered PCs"
},
"print_coverage": {
"type": "boolean",
"default": true,
"description": "Print coverage information"
},
"artifact_prefix": {
"type": "string",
"default": "crash-",
"description": "Prefix for artifact files"
},
"seed": {
"type": "integer",
"description": "Random seed for reproducibility"
},
"python_path": {
"type": "array",
"items": {"type": "string"},
"description": "Additional Python paths to add to sys.path"
},
"enable_sanitizers": {
"type": "boolean",
"default": true,
"description": "Enable Python-specific sanitizers and checks"
},
"detect_leaks": {
"type": "boolean",
"default": true,
"description": "Detect memory leaks in native extensions"
},
"detect_stack_use_after_return": {
"type": "boolean",
"default": false,
"description": "Detect stack use-after-return"
},
"setup_code": {
"type": "string",
"description": "Python code to execute before fuzzing starts"
},
"enable_value_profile": {
"type": "boolean",
"default": false,
"description": "Enable value profiling for better mutation"
},
"shrink": {
"type": "boolean",
"default": true,
"description": "Try to shrink the corpus"
},
"only_ascii": {
"type": "boolean",
"default": false,
"description": "Only generate ASCII inputs"
}
}
},
output_schema={
"type": "object",
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"exception_type": {"type": "string"},
"exception_message": {"type": "string"},
"stack_trace": {"type": "string"},
"crash_input": {"type": "string"}
}
}
}
}
}
)
def validate_config(self, config: Dict[str, Any]) -> bool:
"""Validate configuration"""
target_script = config.get("target_script")
if not target_script:
raise ValueError("target_script is required for Atheris")
max_total_time = config.get("max_total_time", 600)
if max_total_time <= 0:
raise ValueError("max_total_time must be positive")
return True
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
"""Execute Atheris Python fuzzing"""
self.start_timer()
try:
# Validate inputs
self.validate_config(config)
self.validate_workspace(workspace)
logger.info("Running Atheris Python fuzzing")
# Check Atheris installation
await self._check_atheris_installation()
# Validate target script
target_script = workspace / config["target_script"]
if not target_script.exists():
raise FileNotFoundError(f"Target script not found: {target_script}")
# Run Atheris fuzzing
findings = await self._run_atheris_fuzzing(target_script, config, workspace)
# Create summary
summary = self._create_summary(findings)
logger.info(f"Atheris found {len(findings)} issues")
return self.create_result(
findings=findings,
status="success",
summary=summary
)
except Exception as e:
logger.error(f"Atheris module failed: {e}")
return self.create_result(
findings=[],
status="failed",
error=str(e)
)
async def _check_atheris_installation(self):
"""Check if Atheris is installed"""
try:
process = await asyncio.create_subprocess_exec(
sys.executable, "-c", "import atheris; print(atheris.__version__)",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
raise RuntimeError("Atheris not installed. Install with: pip install atheris")
version = stdout.decode().strip()
logger.info(f"Using Atheris version: {version}")
except Exception as e:
raise RuntimeError(f"Atheris installation check failed: {e}")
async def _run_atheris_fuzzing(self, target_script: Path, config: Dict[str, Any], workspace: Path) -> List[ModuleFinding]:
"""Run Atheris fuzzing"""
findings = []
try:
# Create output directory for artifacts
output_dir = workspace / "atheris_output"
output_dir.mkdir(exist_ok=True)
# Create wrapper script for fuzzing
wrapper_script = await self._create_atheris_wrapper(target_script, config, workspace, output_dir)
# Build Atheris command
cmd = [sys.executable, str(wrapper_script)]
# Add corpus directory
corpus_dir = config.get("corpus_dir")
if corpus_dir:
corpus_path = workspace / corpus_dir
if corpus_path.exists():
cmd.append(str(corpus_path))
# Set up environment
env = self._setup_atheris_environment(config)
logger.debug(f"Running command: {' '.join(cmd)}")
# Run Atheris with timeout
max_total_time = config.get("max_total_time", 600)
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=workspace,
env=env
)
# Wait for specified time then terminate
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(), timeout=max_total_time
)
except asyncio.TimeoutError:
logger.info(f"Atheris fuzzing timed out after {max_total_time} seconds")
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=10)
except asyncio.TimeoutError:
process.kill()
await process.wait()
# Parse results
findings = self._parse_atheris_output(
stdout.decode(), stderr.decode(), output_dir, workspace
)
# Look for crash files
crash_findings = self._parse_crash_files(output_dir, workspace)
findings.extend(crash_findings)
except Exception as e:
logger.warning(f"Error running Atheris process: {e}")
except Exception as e:
logger.warning(f"Error in Atheris fuzzing: {e}")
return findings
async def _create_atheris_wrapper(self, target_script: Path, config: Dict[str, Any], workspace: Path, output_dir: Path) -> Path:
"""Create wrapper script for Atheris fuzzing"""
wrapper_path = workspace / "atheris_wrapper.py"
wrapper_code = f'''#!/usr/bin/env python3
import sys
import os
import atheris
import traceback
# Add Python paths
python_paths = {config.get("python_path", [])}
for path in python_paths:
if path not in sys.path:
sys.path.insert(0, path)
# Add workspace to Python path
sys.path.insert(0, r"{workspace}")
# Setup code
setup_code = """{config.get("setup_code", "")}"""
if setup_code:
exec(setup_code)
# Import target script
target_module_name = "{target_script.stem}"
sys.path.insert(0, r"{target_script.parent}")
try:
target_module = __import__(target_module_name)
target_function = getattr(target_module, "{config.get("target_function", "TestOneInput")}")
except Exception as e:
print(f"Failed to import target: {{e}}")
sys.exit(1)
# Wrapper function to catch exceptions
original_target = target_function
def wrapped_target(data):
try:
return original_target(data)
except Exception as e:
# Write crash information
crash_info = {{
"exception_type": type(e).__name__,
"exception_message": str(e),
"stack_trace": traceback.format_exc(),
"input_data": data[:1000].hex() if isinstance(data, bytes) else str(data)[:1000]
}}
crash_file = r"{output_dir}" + "/crash_" + type(e).__name__ + ".txt"
with open(crash_file, "a") as f:
f.write(f"Exception: {{type(e).__name__}}\\n")
f.write(f"Message: {{str(e)}}\\n")
f.write(f"Stack trace:\\n{{traceback.format_exc()}}\\n")
f.write(f"Input data (first 1000 chars/bytes): {{crash_info['input_data']}}\\n")
f.write("-" * 80 + "\\n")
# Re-raise to let Atheris handle it
raise
if __name__ == "__main__":
# Configure Atheris
atheris.Setup(sys.argv, wrapped_target)
# Set Atheris options
options = []
options.append(f"-max_total_time={{config.get('max_total_time', 600)}}")
options.append(f"-max_len={{config.get('max_len', 4096)}}")
options.append(f"-timeout={{config.get('timeout', 25)}}")
options.append(f"-runs={{config.get('runs', -1)}}")
if {config.get('jobs', 1)} > 1:
options.append(f"-jobs={{config.get('jobs', 1)}}")
if {config.get('print_final_stats', True)}:
options.append("-print_final_stats=1")
else:
options.append("-print_final_stats=0")
if {config.get('print_pcs', False)}:
options.append("-print_pcs=1")
if {config.get('print_coverage', True)}:
options.append("-print_coverage=1")
artifact_prefix = "{config.get('artifact_prefix', 'crash-')}"
options.append(f"-artifact_prefix={{r'{output_dir}'}}/" + artifact_prefix)
seed = {config.get('seed')}
if seed is not None:
options.append(f"-seed={{seed}}")
if {config.get('enable_value_profile', False)}:
options.append("-use_value_profile=1")
if {config.get('shrink', True)}:
options.append("-shrink=1")
if {config.get('only_ascii', False)}:
options.append("-only_ascii=1")
dict_file = "{config.get('dict_file', '')}"
if dict_file:
dict_path = r"{workspace}" + "/" + dict_file
if os.path.exists(dict_path):
options.append(f"-dict={{dict_path}}")
# Add options to sys.argv
sys.argv.extend(options)
# Start fuzzing
atheris.Fuzz()
'''
with open(wrapper_path, 'w') as f:
f.write(wrapper_code)
return wrapper_path
def _setup_atheris_environment(self, config: Dict[str, Any]) -> Dict[str, str]:
"""Setup environment variables for Atheris"""
env = os.environ.copy()
# Enable sanitizers if requested
if config.get("enable_sanitizers", True):
env["ASAN_OPTIONS"] = env.get("ASAN_OPTIONS", "") + ":detect_leaks=1:halt_on_error=1"
if config.get("detect_leaks", True):
env["ASAN_OPTIONS"] = env.get("ASAN_OPTIONS", "") + ":detect_leaks=1"
if config.get("detect_stack_use_after_return", False):
env["ASAN_OPTIONS"] = env.get("ASAN_OPTIONS", "") + ":detect_stack_use_after_return=1"
return env
def _parse_atheris_output(self, stdout: str, stderr: str, output_dir: Path, workspace: Path) -> List[ModuleFinding]:
"""Parse Atheris output for crashes and issues"""
findings = []
try:
# Combine stdout and stderr
full_output = stdout + "\n" + stderr
# Look for Python exceptions in output
exception_patterns = [
r"Traceback \(most recent call last\):(.*?)(?=\n\w|\nDONE|\n=|\Z)",
r"Exception: (\w+).*?\nMessage: (.*?)\nStack trace:\n(.*?)(?=\n-{20,}|\Z)"
]
for pattern in exception_patterns:
import re
matches = re.findall(pattern, full_output, re.DOTALL | re.MULTILINE)
for match in matches:
finding = self._create_exception_finding(match, full_output, output_dir)
if finding:
findings.append(finding)
except Exception as e:
logger.warning(f"Error parsing Atheris output: {e}")
return findings
def _parse_crash_files(self, output_dir: Path, workspace: Path) -> List[ModuleFinding]:
"""Parse crash files created by wrapper"""
findings = []
try:
# Look for crash files
crash_files = list(output_dir.glob("crash_*.txt"))
for crash_file in crash_files:
findings.extend(self._parse_crash_file(crash_file, workspace))
# Also look for Atheris artifact files
artifact_files = list(output_dir.glob("crash-*"))
for artifact_file in artifact_files:
finding = self._create_artifact_finding(artifact_file, workspace)
if finding:
findings.append(finding)
except Exception as e:
logger.warning(f"Error parsing crash files: {e}")
return findings
def _parse_crash_file(self, crash_file: Path, workspace: Path) -> List[ModuleFinding]:
"""Parse individual crash file"""
findings = []
try:
content = crash_file.read_text()
# Split by separator
crash_entries = content.split("-" * 80)
for entry in crash_entries:
if not entry.strip():
continue
finding = self._parse_crash_entry(entry, crash_file, workspace)
if finding:
findings.append(finding)
except Exception as e:
logger.warning(f"Error parsing crash file {crash_file}: {e}")
return findings
def _parse_crash_entry(self, entry: str, crash_file: Path, workspace: Path) -> ModuleFinding:
"""Parse individual crash entry"""
try:
lines = entry.strip().split('\n')
exception_type = ""
exception_message = ""
stack_trace = ""
input_data = ""
current_section = None
stack_lines = []
for line in lines:
if line.startswith("Exception: "):
exception_type = line.replace("Exception: ", "")
elif line.startswith("Message: "):
exception_message = line.replace("Message: ", "")
elif line.startswith("Stack trace:"):
current_section = "stack"
elif line.startswith("Input data"):
current_section = "input"
input_data = line.split(":", 1)[1].strip() if ":" in line else ""
elif current_section == "stack":
stack_lines.append(line)
stack_trace = '\n'.join(stack_lines)
if not exception_type:
return None
# Determine severity based on exception type
severity = self._get_exception_severity(exception_type)
# Create relative path
try:
rel_path = crash_file.relative_to(workspace)
file_path = str(rel_path)
except ValueError:
file_path = str(crash_file)
finding = self.create_finding(
title=f"Atheris Exception: {exception_type}",
description=f"Atheris discovered a Python exception: {exception_type}{': ' + exception_message if exception_message else ''}",
severity=severity,
category=self._get_exception_category(exception_type),
file_path=file_path,
recommendation=self._get_exception_recommendation(exception_type, exception_message),
metadata={
"exception_type": exception_type,
"exception_message": exception_message,
"stack_trace": stack_trace[:2000] if stack_trace else "", # Limit size
"crash_input_preview": input_data[:500] if input_data else "",
"fuzzer": "atheris"
}
)
return finding
except Exception as e:
logger.warning(f"Error parsing crash entry: {e}")
return None
def _create_exception_finding(self, match, full_output: str, output_dir: Path) -> ModuleFinding:
"""Create finding from exception match"""
try:
if isinstance(match, tuple) and len(match) >= 1:
# Handle different match formats
if len(match) == 3: # Exception format
exception_type, exception_message, stack_trace = match
else:
stack_trace = match[0]
exception_type = "Unknown"
exception_message = ""
else:
stack_trace = str(match)
exception_type = "Unknown"
exception_message = ""
# Try to extract exception type from stack trace
if not exception_type or exception_type == "Unknown":
lines = stack_trace.split('\n')
for line in reversed(lines):
if ':' in line and any(exc in line for exc in ['Error', 'Exception', 'Warning']):
exception_type = line.split(':')[0].strip()
exception_message = line.split(':', 1)[1].strip() if ':' in line else ""
break
severity = self._get_exception_severity(exception_type)
finding = self.create_finding(
title=f"Atheris Exception: {exception_type}",
description=f"Atheris discovered a Python exception during fuzzing: {exception_type}",
severity=severity,
category=self._get_exception_category(exception_type),
file_path=None,
recommendation=self._get_exception_recommendation(exception_type, exception_message),
metadata={
"exception_type": exception_type,
"exception_message": exception_message,
"stack_trace": stack_trace[:2000] if stack_trace else "",
"fuzzer": "atheris"
}
)
return finding
except Exception as e:
logger.warning(f"Error creating exception finding: {e}")
return None
def _create_artifact_finding(self, artifact_file: Path, workspace: Path) -> ModuleFinding:
"""Create finding from Atheris artifact file"""
try:
# Try to read artifact content (limited)
artifact_content = ""
try:
content_bytes = artifact_file.read_bytes()[:1000]
artifact_content = content_bytes.hex()
except Exception:
pass
# Create relative path
try:
rel_path = artifact_file.relative_to(workspace)
file_path = str(rel_path)
except ValueError:
file_path = str(artifact_file)
finding = self.create_finding(
title="Atheris Crash Artifact",
description=f"Atheris generated a crash artifact file: {artifact_file.name}",
severity="medium",
category="program_crash",
file_path=file_path,
recommendation="Analyze the crash artifact to reproduce and debug the issue. The artifact contains the input that caused the crash.",
metadata={
"artifact_type": "crash",
"artifact_file": artifact_file.name,
"artifact_content_hex": artifact_content,
"fuzzer": "atheris"
}
)
return finding
except Exception as e:
logger.warning(f"Error creating artifact finding: {e}")
return None
def _get_exception_severity(self, exception_type: str) -> str:
"""Determine severity based on exception type"""
if not exception_type:
return "medium"
exception_lower = exception_type.lower()
# Critical security issues
if any(term in exception_lower for term in ["segmentationfault", "accessviolation", "memoryerror"]):
return "critical"
# High severity exceptions
elif any(term in exception_lower for term in ["attributeerror", "typeerror", "indexerror", "keyerror", "valueerror"]):
return "high"
# Medium severity exceptions
elif any(term in exception_lower for term in ["assertionerror", "runtimeerror", "ioerror", "oserror"]):
return "medium"
# Lower severity exceptions
elif any(term in exception_lower for term in ["warning", "deprecation"]):
return "low"
else:
return "medium"
def _get_exception_category(self, exception_type: str) -> str:
"""Determine category based on exception type"""
if not exception_type:
return "python_exception"
exception_lower = exception_type.lower()
if any(term in exception_lower for term in ["memory", "segmentation", "access"]):
return "memory_corruption"
elif any(term in exception_lower for term in ["attribute", "type"]):
return "type_error"
elif any(term in exception_lower for term in ["index", "key", "value"]):
return "data_error"
elif any(term in exception_lower for term in ["io", "os", "file"]):
return "io_error"
elif any(term in exception_lower for term in ["assertion"]):
return "assertion_failure"
else:
return "python_exception"
def _get_exception_recommendation(self, exception_type: str, exception_message: str) -> str:
"""Generate recommendation based on exception type"""
if not exception_type:
return "Analyze the exception and fix the underlying code issue."
exception_lower = exception_type.lower()
if "attributeerror" in exception_lower:
return "Fix AttributeError by ensuring objects have the expected attributes before accessing them. Add proper error handling and validation."
elif "typeerror" in exception_lower:
return "Fix TypeError by ensuring correct data types are used. Add type checking and validation for function parameters."
elif "indexerror" in exception_lower:
return "Fix IndexError by adding bounds checking before accessing list/array elements. Validate indices are within valid range."
elif "keyerror" in exception_lower:
return "Fix KeyError by checking if keys exist in dictionaries before accessing them. Use .get() method or proper key validation."
elif "valueerror" in exception_lower:
return "Fix ValueError by validating input values before processing. Add proper input sanitization and validation."
elif "memoryerror" in exception_lower:
return "Fix MemoryError by optimizing memory usage, processing data in chunks, or increasing available memory."
elif "assertionerror" in exception_lower:
return "Fix AssertionError by reviewing assertion conditions and ensuring they properly validate the expected state."
else:
return f"Fix the {exception_type} exception by analyzing the root cause and implementing appropriate error handling and validation."
def _create_summary(self, findings: List[ModuleFinding]) -> Dict[str, Any]:
"""Create analysis summary"""
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
category_counts = {}
exception_counts = {}
for finding in findings:
# Count by severity
severity_counts[finding.severity] += 1
# Count by category
category = finding.category
category_counts[category] = category_counts.get(category, 0) + 1
# Count by exception type
exception_type = finding.metadata.get("exception_type", "unknown")
exception_counts[exception_type] = exception_counts.get(exception_type, 0) + 1
return {
"total_findings": len(findings),
"severity_counts": severity_counts,
"category_counts": category_counts,
"exception_counts": exception_counts,
"unique_exceptions": len(exception_counts),
"python_specific_issues": sum(category_counts.get(cat, 0) for cat in ["type_error", "data_error", "python_exception"])
}
@@ -0,0 +1,572 @@
"""
Cargo Fuzz Module
This module uses cargo-fuzz for fuzzing Rust code with libFuzzer integration.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import asyncio
import json
import os
from pathlib import Path
from typing import Dict, Any, List, Tuple
import subprocess
import logging
import httpx
import re
from datetime import datetime, timedelta
try:
from prefect import get_run_context
except ImportError:
# Fallback for when not running in Prefect context
get_run_context = None
from ..base import BaseModule, ModuleMetadata, ModuleFinding, ModuleResult
from . import register_module
logger = logging.getLogger(__name__)
@register_module
class CargoFuzzModule(BaseModule):
"""Cargo Fuzz Rust fuzzing module"""
def get_metadata(self) -> ModuleMetadata:
"""Get module metadata"""
return ModuleMetadata(
name="cargo_fuzz",
version="0.11.2",
description="Rust fuzzing integration with libFuzzer using cargo-fuzz",
author="FuzzForge Team",
category="fuzzing",
tags=["rust", "libfuzzer", "cargo", "coverage-guided", "sanitizers"],
input_schema={
"type": "object",
"properties": {
"project_dir": {
"type": "string",
"description": "Path to Rust project directory (with Cargo.toml)"
},
"fuzz_target": {
"type": "string",
"description": "Name of the fuzz target to run"
},
"max_total_time": {
"type": "integer",
"default": 600,
"description": "Maximum total time to run fuzzing (seconds)"
},
"jobs": {
"type": "integer",
"default": 1,
"description": "Number of worker processes"
},
"corpus_dir": {
"type": "string",
"description": "Custom corpus directory"
},
"artifacts_dir": {
"type": "string",
"description": "Custom artifacts directory"
},
"sanitizer": {
"type": "string",
"enum": ["address", "memory", "thread", "leak", "none"],
"default": "address",
"description": "Sanitizer to use"
},
"release": {
"type": "boolean",
"default": False,
"description": "Use release mode"
},
"debug_assertions": {
"type": "boolean",
"default": True,
"description": "Enable debug assertions"
}
}
},
output_schema={
"type": "object",
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"crash_type": {"type": "string"},
"artifact_path": {"type": "string"},
"stack_trace": {"type": "string"}
}
}
}
}
}
)
def validate_config(self, config: Dict[str, Any]) -> bool:
"""Validate configuration"""
project_dir = config.get("project_dir")
if not project_dir:
raise ValueError("project_dir is required")
fuzz_target = config.get("fuzz_target")
if not fuzz_target:
raise ValueError("fuzz_target is required")
return True
async def execute(self, config: Dict[str, Any], workspace: Path, stats_callback=None) -> ModuleResult:
"""Execute cargo-fuzz fuzzing"""
self.start_timer()
try:
# Initialize last observed stats for summary propagation
self._last_stats = {
'executions': 0,
'executions_per_sec': 0.0,
'crashes': 0,
'corpus_size': 0,
'elapsed_time': 0,
}
# Validate inputs
self.validate_config(config)
self.validate_workspace(workspace)
logger.info("Running cargo-fuzz Rust fuzzing")
# Check installation
await self._check_cargo_fuzz_installation()
# Setup project
project_dir = workspace / config["project_dir"]
await self._setup_cargo_fuzz_project(project_dir, config)
# Run fuzzing
findings = await self._run_cargo_fuzz(project_dir, config, workspace, stats_callback)
# Create summary and enrich with last observed runtime stats
summary = self._create_summary(findings)
try:
summary.update({
'executions': self._last_stats.get('executions', 0),
'executions_per_sec': self._last_stats.get('executions_per_sec', 0.0),
'corpus_size': self._last_stats.get('corpus_size', 0),
'crashes': self._last_stats.get('crashes', 0),
'elapsed_time': self._last_stats.get('elapsed_time', 0),
})
except Exception:
pass
logger.info(f"cargo-fuzz found {len(findings)} issues")
return self.create_result(
findings=findings,
status="success",
summary=summary
)
except Exception as e:
logger.error(f"cargo-fuzz module failed: {e}")
return self.create_result(
findings=[],
status="failed",
error=str(e)
)
async def _check_cargo_fuzz_installation(self):
"""Check if cargo-fuzz is installed"""
try:
process = await asyncio.create_subprocess_exec(
"cargo", "fuzz", "--version",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
raise RuntimeError("cargo-fuzz not installed. Install with: cargo install cargo-fuzz")
except Exception as e:
raise RuntimeError(f"cargo-fuzz installation check failed: {e}")
async def _setup_cargo_fuzz_project(self, project_dir: Path, config: Dict[str, Any]):
"""Setup cargo-fuzz project"""
if not project_dir.exists():
raise FileNotFoundError(f"Project directory not found: {project_dir}")
cargo_toml = project_dir / "Cargo.toml"
if not cargo_toml.exists():
raise FileNotFoundError(f"Cargo.toml not found in {project_dir}")
# Check if fuzz directory exists, if not initialize
fuzz_dir = project_dir / "fuzz"
if not fuzz_dir.exists():
logger.info("Initializing cargo-fuzz project")
process = await asyncio.create_subprocess_exec(
"cargo", "fuzz", "init",
cwd=project_dir,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
await process.communicate()
async def _run_cargo_fuzz(self, project_dir: Path, config: Dict[str, Any], workspace: Path, stats_callback=None) -> List[ModuleFinding]:
"""Run cargo-fuzz with real-time statistics reporting"""
findings = []
# Get run_id from Prefect context for statistics reporting
run_id = None
if get_run_context:
try:
context = get_run_context()
run_id = str(context.flow_run.id)
except Exception:
logger.warning("Could not get run_id from Prefect context")
try:
# Build command
cmd = ["cargo", "fuzz", "run", config["fuzz_target"]]
# Add options
if config.get("jobs", 1) > 1:
cmd.extend(["--", f"-jobs={config['jobs']}"])
max_time = config.get("max_total_time", 600)
cmd.extend(["--", f"-max_total_time={max_time}"])
# Set sanitizer
sanitizer = config.get("sanitizer", "address")
if sanitizer != "none":
cmd.append(f"--sanitizer={sanitizer}")
if config.get("release", False):
cmd.append("--release")
# Set environment
env = os.environ.copy()
if config.get("debug_assertions", True):
env["RUSTFLAGS"] = env.get("RUSTFLAGS", "") + " -C debug-assertions=on"
logger.debug(f"Running command: {' '.join(cmd)}")
# Run with streaming output processing for real-time stats
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT, # Merge stderr into stdout
cwd=project_dir,
env=env
)
# Process output in real-time
stdout_data, stderr_data = await self._process_streaming_output(
process, max_time, config, stats_callback
)
# Parse final results
findings = self._parse_cargo_fuzz_output(
stdout_data, stderr_data, project_dir, workspace, config
)
except Exception as e:
logger.warning(f"Error running cargo-fuzz: {e}")
except Exception as e:
logger.warning(f"Error in cargo-fuzz execution: {e}")
return findings
def _parse_cargo_fuzz_output(self, stdout: str, stderr: str, project_dir: Path, workspace: Path, config: Dict[str, Any]) -> List[ModuleFinding]:
"""Parse cargo-fuzz output"""
findings = []
try:
full_output = stdout + "\n" + stderr
# Look for crash artifacts
artifacts_dir = project_dir / "fuzz" / "artifacts" / config["fuzz_target"]
if artifacts_dir.exists():
for artifact in artifacts_dir.iterdir():
if artifact.is_file():
finding = self._create_artifact_finding(artifact, workspace, full_output)
if finding:
findings.append(finding)
except Exception as e:
logger.warning(f"Error parsing cargo-fuzz output: {e}")
return findings
def _create_artifact_finding(self, artifact_path: Path, workspace: Path, output: str) -> ModuleFinding:
"""Create finding from artifact file"""
try:
# Try to determine crash type from filename or content
crash_type = "crash"
if "leak" in artifact_path.name.lower():
crash_type = "memory_leak"
elif "timeout" in artifact_path.name.lower():
crash_type = "timeout"
# Extract stack trace from output
stack_trace = self._extract_stack_trace_from_output(output, artifact_path.name)
try:
rel_path = artifact_path.relative_to(workspace)
file_path = str(rel_path)
except ValueError:
file_path = str(artifact_path)
severity = "high" if "crash" in crash_type else "medium"
finding = self.create_finding(
title=f"cargo-fuzz {crash_type.title()}",
description=f"cargo-fuzz discovered a {crash_type} in the Rust code",
severity=severity,
category=self._get_crash_category(crash_type),
file_path=file_path,
recommendation=self._get_crash_recommendation(crash_type),
metadata={
"crash_type": crash_type,
"artifact_path": str(artifact_path),
"stack_trace": stack_trace,
"fuzzer": "cargo_fuzz"
}
)
return finding
except Exception as e:
logger.warning(f"Error creating artifact finding: {e}")
return None
def _extract_stack_trace_from_output(self, output: str, artifact_name: str) -> str:
"""Extract stack trace from output"""
try:
lines = output.split('\n')
stack_lines = []
in_stack = False
for line in lines:
if artifact_name in line or "stack backtrace:" in line.lower():
in_stack = True
continue
if in_stack:
if line.strip() and ("at " in line or "::" in line or line.strip().startswith("0:")):
stack_lines.append(line.strip())
elif not line.strip() and stack_lines:
break
return '\n'.join(stack_lines[:20]) # Limit stack trace size
except Exception:
return ""
def _get_crash_category(self, crash_type: str) -> str:
"""Get category for crash type"""
if "leak" in crash_type:
return "memory_leak"
elif "timeout" in crash_type:
return "performance_issues"
else:
return "memory_safety"
def _get_crash_recommendation(self, crash_type: str) -> str:
"""Get recommendation for crash type"""
if "leak" in crash_type:
return "Fix memory leak by ensuring proper cleanup of allocated resources. Review memory management patterns."
elif "timeout" in crash_type:
return "Fix timeout by optimizing performance, avoiding infinite loops, and implementing reasonable bounds."
else:
return "Fix the crash by analyzing the stack trace and addressing memory safety issues."
async def _process_streaming_output(self, process, max_time: int, config: Dict[str, Any], stats_callback=None) -> Tuple[str, str]:
"""Process cargo-fuzz output in real-time and report statistics"""
stdout_lines = []
start_time = datetime.utcnow()
last_update = start_time
stats_data = {
'executions': 0,
'executions_per_sec': 0.0,
'crashes': 0,
'corpus_size': 0,
'elapsed_time': 0
}
# Get run_id from Prefect context for statistics reporting
run_id = None
if get_run_context:
try:
context = get_run_context()
run_id = str(context.flow_run.id)
except Exception:
logger.debug("Could not get run_id from Prefect context")
try:
# Emit an initial baseline update so dashboards show activity immediately
try:
await self._send_stats_via_callback(stats_callback, run_id, stats_data)
except Exception:
pass
# Monitor process output in chunks to capture libFuzzer carriage-return updates
buffer = ""
while True:
try:
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=1.0)
if not chunk:
# Process finished
break
buffer += chunk.decode('utf-8', errors='ignore')
# Split on both newline and carriage return
if "\n" in buffer or "\r" in buffer:
parts = re.split(r"[\r\n]", buffer)
buffer = parts[-1]
for part in parts[:-1]:
line = part.strip()
if not line:
continue
stdout_lines.append(line)
self._parse_stats_from_line(line, stats_data)
except asyncio.TimeoutError:
# No output this second; continue to periodic update check
pass
# Periodic update (even if there was no output)
current_time = datetime.utcnow()
stats_data['elapsed_time'] = int((current_time - start_time).total_seconds())
if current_time - last_update >= timedelta(seconds=3):
try:
self._last_stats = dict(stats_data)
except Exception:
pass
await self._send_stats_via_callback(stats_callback, run_id, stats_data)
last_update = current_time
# Check if max time exceeded
if stats_data['elapsed_time'] >= max_time:
logger.info("Max time reached, terminating cargo-fuzz")
process.terminate()
break
# Wait for process to complete
await process.wait()
# Send final stats update
try:
self._last_stats = dict(stats_data)
except Exception:
pass
await self._send_stats_via_callback(stats_callback, run_id, stats_data)
except Exception as e:
logger.warning(f"Error processing streaming output: {e}")
stdout_data = '\n'.join(stdout_lines)
return stdout_data, ""
def _parse_stats_from_line(self, line: str, stats_data: Dict[str, Any]):
"""Parse statistics from a cargo-fuzz output line"""
try:
# cargo-fuzz typically shows stats like:
# "#12345: DONE cov: 1234 ft: 5678 corp: 9/10Mb exec/s: 1500 rss: 234Mb"
# "#12345: NEW cov: 1234 ft: 5678 corp: 9/10Mb exec/s: 1500 rss: 234Mb L: 45/67 MS: 3 ..."
# Extract execution count (the #number)
exec_match = re.search(r'#(\d+)(?::)?', line)
if exec_match:
stats_data['executions'] = int(exec_match.group(1))
else:
# libFuzzer stats format alternative
exec_alt = re.search(r'stat::number_of_executed_units:\s*(\d+)', line)
if exec_alt:
stats_data['executions'] = int(exec_alt.group(1))
else:
exec_alt2 = re.search(r'executed units:?\s*(\d+)', line, re.IGNORECASE)
if exec_alt2:
stats_data['executions'] = int(exec_alt2.group(1))
# Extract executions per second
exec_per_sec_match = re.search(r'exec/s:\s*([0-9\.]+)', line)
if exec_per_sec_match:
stats_data['executions_per_sec'] = float(exec_per_sec_match.group(1))
else:
eps_alt = re.search(r'stat::execs_per_sec:\s*([0-9\.]+)', line)
if eps_alt:
stats_data['executions_per_sec'] = float(eps_alt.group(1))
# Extract corpus size (corp: X/YMb)
corp_match = re.search(r'corp(?:us)?:\s*(\d+)', line)
if corp_match:
stats_data['corpus_size'] = int(corp_match.group(1))
# Look for crash indicators
if any(keyword in line.lower() for keyword in ['crash', 'assert', 'panic', 'abort']):
stats_data['crashes'] += 1
except Exception as e:
logger.debug(f"Error parsing stats from line '{line}': {e}")
async def _send_stats_via_callback(self, stats_callback, run_id: str, stats_data: Dict[str, Any]):
"""Send statistics update via callback function"""
if not stats_callback or not run_id:
return
try:
# Prepare statistics payload
stats_payload = {
"run_id": run_id,
"workflow": "language_fuzzing",
"executions": stats_data['executions'],
"executions_per_sec": stats_data['executions_per_sec'],
"crashes": stats_data['crashes'],
"unique_crashes": stats_data['crashes'], # Assume all crashes are unique for now
"corpus_size": stats_data['corpus_size'],
"elapsed_time": stats_data['elapsed_time'],
"timestamp": datetime.utcnow().isoformat()
}
# Call the callback function provided by the Prefect task
await stats_callback(stats_payload)
logger.info(
"LIVE STATS SENT: exec=%s eps=%.2f crashes=%s corpus=%s elapsed=%s",
stats_data['executions'],
stats_data['executions_per_sec'],
stats_data['crashes'],
stats_data['corpus_size'],
stats_data['elapsed_time'],
)
except Exception as e:
logger.debug(f"Error sending stats via callback: {e}")
def _create_summary(self, findings: List[ModuleFinding]) -> Dict[str, Any]:
"""Create analysis summary"""
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
category_counts = {}
for finding in findings:
severity_counts[finding.severity] += 1
category_counts[finding.category] = category_counts.get(finding.category, 0) + 1
return {
"total_findings": len(findings),
"severity_counts": severity_counts,
"category_counts": category_counts
}
+384
View File
@@ -0,0 +1,384 @@
"""
Go-Fuzz Module
This module uses go-fuzz for coverage-guided fuzzing of Go packages.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import asyncio
import json
import os
from pathlib import Path
from typing import Dict, Any, List
import subprocess
import logging
from ..base import BaseModule, ModuleMetadata, ModuleFinding, ModuleResult
from . import register_module
logger = logging.getLogger(__name__)
@register_module
class GoFuzzModule(BaseModule):
"""Go-Fuzz Go language fuzzing module"""
def get_metadata(self) -> ModuleMetadata:
"""Get module metadata"""
return ModuleMetadata(
name="go_fuzz",
version="1.2.0",
description="Coverage-guided fuzzing for Go packages using go-fuzz",
author="FuzzForge Team",
category="fuzzing",
tags=["go", "golang", "coverage-guided", "packages"],
input_schema={
"type": "object",
"properties": {
"package_path": {
"type": "string",
"description": "Path to Go package to fuzz"
},
"fuzz_function": {
"type": "string",
"default": "Fuzz",
"description": "Name of the fuzz function"
},
"workdir": {
"type": "string",
"default": "go_fuzz_workdir",
"description": "Working directory for go-fuzz"
},
"procs": {
"type": "integer",
"default": 1,
"description": "Number of parallel processes"
},
"timeout": {
"type": "integer",
"default": 600,
"description": "Total fuzzing timeout (seconds)"
},
"race": {
"type": "boolean",
"default": false,
"description": "Enable race detector"
},
"minimize": {
"type": "boolean",
"default": true,
"description": "Minimize crashers"
},
"sonar": {
"type": "boolean",
"default": false,
"description": "Enable sonar mode"
}
}
},
output_schema={
"type": "object",
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"crash_type": {"type": "string"},
"crash_file": {"type": "string"},
"stack_trace": {"type": "string"}
}
}
}
}
}
)
def validate_config(self, config: Dict[str, Any]) -> bool:
"""Validate configuration"""
package_path = config.get("package_path")
if not package_path:
raise ValueError("package_path is required")
return True
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
"""Execute go-fuzz fuzzing"""
self.start_timer()
try:
# Validate inputs
self.validate_config(config)
self.validate_workspace(workspace)
logger.info("Running go-fuzz Go fuzzing")
# Check installation
await self._check_go_fuzz_installation()
# Setup
package_path = workspace / config["package_path"]
workdir = workspace / config.get("workdir", "go_fuzz_workdir")
# Build and run
findings = await self._run_go_fuzz(package_path, workdir, config, workspace)
# Create summary
summary = self._create_summary(findings)
logger.info(f"go-fuzz found {len(findings)} issues")
return self.create_result(
findings=findings,
status="success",
summary=summary
)
except Exception as e:
logger.error(f"go-fuzz module failed: {e}")
return self.create_result(
findings=[],
status="failed",
error=str(e)
)
async def _check_go_fuzz_installation(self):
"""Check if go-fuzz is installed"""
try:
process = await asyncio.create_subprocess_exec(
"go-fuzz", "--help",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
await process.communicate()
if process.returncode != 0:
# Try building
process = await asyncio.create_subprocess_exec(
"go", "install", "github.com/dvyukov/go-fuzz/go-fuzz@latest",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
await process.communicate()
except Exception as e:
raise RuntimeError(f"go-fuzz installation failed: {e}")
async def _run_go_fuzz(self, package_path: Path, workdir: Path, config: Dict[str, Any], workspace: Path) -> List[ModuleFinding]:
"""Run go-fuzz"""
findings = []
try:
# Create workdir
workdir.mkdir(exist_ok=True)
# Build
await self._build_go_fuzz(package_path, config)
# Run fuzzing
cmd = ["go-fuzz", "-bin", f"{package_path.name}-fuzz.zip", "-workdir", str(workdir)]
if config.get("procs", 1) > 1:
cmd.extend(["-procs", str(config["procs"])])
if config.get("race", False):
cmd.append("-race")
if config.get("sonar", False):
cmd.append("-sonar")
timeout = config.get("timeout", 600)
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=package_path.parent
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(), timeout=timeout
)
except asyncio.TimeoutError:
process.terminate()
await process.wait()
# Parse results
findings = self._parse_go_fuzz_results(workdir, workspace, config)
except Exception as e:
logger.warning(f"Error running go-fuzz: {e}")
except Exception as e:
logger.warning(f"Error in go-fuzz execution: {e}")
return findings
async def _build_go_fuzz(self, package_path: Path, config: Dict[str, Any]):
"""Build go-fuzz binary"""
cmd = ["go-fuzz-build"]
if config.get("race", False):
cmd.append("-race")
process = await asyncio.create_subprocess_exec(
*cmd,
cwd=package_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
raise RuntimeError(f"go-fuzz-build failed: {stderr.decode()}")
def _parse_go_fuzz_results(self, workdir: Path, workspace: Path, config: Dict[str, Any]) -> List[ModuleFinding]:
"""Parse go-fuzz results"""
findings = []
try:
# Look for crashers
crashers_dir = workdir / "crashers"
if crashers_dir.exists():
for crash_file in crashers_dir.iterdir():
if crash_file.is_file() and not crash_file.name.startswith("."):
finding = self._create_crash_finding(crash_file, workspace)
if finding:
findings.append(finding)
# Look for suppressions (potential issues)
suppressions_dir = workdir / "suppressions"
if suppressions_dir.exists():
for supp_file in suppressions_dir.iterdir():
if supp_file.is_file():
finding = self._create_suppression_finding(supp_file, workspace)
if finding:
findings.append(finding)
except Exception as e:
logger.warning(f"Error parsing go-fuzz results: {e}")
return findings
def _create_crash_finding(self, crash_file: Path, workspace: Path) -> ModuleFinding:
"""Create finding from crash file"""
try:
# Read crash output
crash_content = ""
if crash_file.name.endswith(".output"):
crash_content = crash_file.read_text()
# Determine crash type
crash_type = "panic"
if "runtime error" in crash_content:
crash_type = "runtime_error"
elif "race" in crash_content:
crash_type = "race_condition"
try:
rel_path = crash_file.relative_to(workspace)
file_path = str(rel_path)
except ValueError:
file_path = str(crash_file)
finding = self.create_finding(
title=f"go-fuzz {crash_type.title()}",
description=f"go-fuzz discovered a {crash_type} in the Go code",
severity=self._get_crash_severity(crash_type),
category=self._get_crash_category(crash_type),
file_path=file_path,
recommendation=self._get_crash_recommendation(crash_type),
metadata={
"crash_type": crash_type,
"crash_file": str(crash_file),
"stack_trace": crash_content[:1000],
"fuzzer": "go_fuzz"
}
)
return finding
except Exception as e:
logger.warning(f"Error creating crash finding: {e}")
return None
def _create_suppression_finding(self, supp_file: Path, workspace: Path) -> ModuleFinding:
"""Create finding from suppression file"""
try:
try:
rel_path = supp_file.relative_to(workspace)
file_path = str(rel_path)
except ValueError:
file_path = str(supp_file)
finding = self.create_finding(
title="go-fuzz Potential Issue",
description="go-fuzz identified a potential issue that was suppressed",
severity="low",
category="potential_issue",
file_path=file_path,
recommendation="Review suppressed issue to determine if it requires attention.",
metadata={
"suppression_file": str(supp_file),
"fuzzer": "go_fuzz"
}
)
return finding
except Exception as e:
logger.warning(f"Error creating suppression finding: {e}")
return None
def _get_crash_severity(self, crash_type: str) -> str:
"""Get crash severity"""
if crash_type == "race_condition":
return "high"
elif crash_type == "runtime_error":
return "high"
else:
return "medium"
def _get_crash_category(self, crash_type: str) -> str:
"""Get crash category"""
if crash_type == "race_condition":
return "race_condition"
elif crash_type == "runtime_error":
return "runtime_error"
else:
return "program_crash"
def _get_crash_recommendation(self, crash_type: str) -> str:
"""Get crash recommendation"""
if crash_type == "race_condition":
return "Fix race condition by adding proper synchronization (mutexes, channels, etc.)"
elif crash_type == "runtime_error":
return "Fix runtime error by adding bounds checking and proper error handling"
else:
return "Analyze the crash and fix the underlying issue"
def _create_summary(self, findings: List[ModuleFinding]) -> Dict[str, Any]:
"""Create analysis summary"""
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
category_counts = {}
for finding in findings:
severity_counts[finding.severity] += 1
category_counts[finding.category] = category_counts.get(finding.category, 0) + 1
return {
"total_findings": len(findings),
"severity_counts": severity_counts,
"category_counts": category_counts
}
@@ -0,0 +1,705 @@
"""
LibFuzzer Fuzzing Module
This module uses LibFuzzer (LLVM's coverage-guided fuzzing engine) to find
bugs and security vulnerabilities in C/C++ code.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import asyncio
import json
import os
from pathlib import Path
from typing import Dict, Any, List
import subprocess
import logging
import re
from ..base import BaseModule, ModuleMetadata, ModuleFinding, ModuleResult
from . import register_module
logger = logging.getLogger(__name__)
@register_module
class LibFuzzerModule(BaseModule):
"""LibFuzzer coverage-guided fuzzing module"""
def get_metadata(self) -> ModuleMetadata:
"""Get module metadata"""
return ModuleMetadata(
name="libfuzzer",
version="17.0.0",
description="LLVM's coverage-guided fuzzing engine for finding bugs in C/C++ code",
author="FuzzForge Team",
category="fuzzing",
tags=["coverage-guided", "c", "cpp", "llvm", "sanitizers", "memory-safety"],
input_schema={
"type": "object",
"properties": {
"target_binary": {
"type": "string",
"description": "Path to the fuzz target binary (compiled with -fsanitize=fuzzer)"
},
"corpus_dir": {
"type": "string",
"description": "Directory containing initial corpus files"
},
"dict_file": {
"type": "string",
"description": "Dictionary file for fuzzing keywords"
},
"max_total_time": {
"type": "integer",
"default": 600,
"description": "Maximum total time to run fuzzing (seconds)"
},
"max_len": {
"type": "integer",
"default": 4096,
"description": "Maximum length of test input"
},
"timeout": {
"type": "integer",
"default": 25,
"description": "Timeout for individual test cases (seconds)"
},
"runs": {
"type": "integer",
"default": -1,
"description": "Number of individual test runs (-1 for unlimited)"
},
"jobs": {
"type": "integer",
"default": 1,
"description": "Number of fuzzing jobs to run in parallel"
},
"workers": {
"type": "integer",
"default": 1,
"description": "Number of workers for parallel fuzzing"
},
"reload": {
"type": "integer",
"default": 1,
"description": "Reload the main corpus periodically"
},
"print_final_stats": {
"type": "boolean",
"default": true,
"description": "Print final statistics"
},
"print_pcs": {
"type": "boolean",
"default": false,
"description": "Print newly covered PCs"
},
"print_funcs": {
"type": "boolean",
"default": false,
"description": "Print newly covered functions"
},
"print_coverage": {
"type": "boolean",
"default": true,
"description": "Print coverage information"
},
"shrink": {
"type": "boolean",
"default": true,
"description": "Try to shrink the corpus"
},
"reduce_inputs": {
"type": "boolean",
"default": true,
"description": "Try to reduce the size of inputs"
},
"use_value_profile": {
"type": "boolean",
"default": false,
"description": "Use value profile for fuzzing"
},
"sanitizers": {
"type": "array",
"items": {"type": "string", "enum": ["address", "memory", "undefined", "thread", "leak"]},
"default": ["address"],
"description": "Sanitizers to use during fuzzing"
},
"artifact_prefix": {
"type": "string",
"default": "crash-",
"description": "Prefix for artifact files"
},
"exact_artifact_path": {
"type": "string",
"description": "Exact path for artifact files"
},
"fork": {
"type": "integer",
"default": 0,
"description": "Fork mode (number of simultaneous processes)"
},
"ignore_crashes": {
"type": "boolean",
"default": false,
"description": "Ignore crashes and continue fuzzing"
},
"ignore_timeouts": {
"type": "boolean",
"default": false,
"description": "Ignore timeouts and continue fuzzing"
},
"ignore_ooms": {
"type": "boolean",
"default": false,
"description": "Ignore out-of-memory and continue fuzzing"
}
}
},
output_schema={
"type": "object",
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"crash_type": {"type": "string"},
"crash_file": {"type": "string"},
"stack_trace": {"type": "string"},
"sanitizer": {"type": "string"}
}
}
}
}
}
)
def validate_config(self, config: Dict[str, Any]) -> bool:
"""Validate configuration"""
target_binary = config.get("target_binary")
if not target_binary:
raise ValueError("target_binary is required for LibFuzzer")
max_total_time = config.get("max_total_time", 600)
if max_total_time <= 0:
raise ValueError("max_total_time must be positive")
return True
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
"""Execute LibFuzzer fuzzing"""
self.start_timer()
try:
# Validate inputs
self.validate_config(config)
self.validate_workspace(workspace)
logger.info("Running LibFuzzer fuzzing campaign")
# Check if target binary exists
target_binary = workspace / config["target_binary"]
if not target_binary.exists():
raise FileNotFoundError(f"Target binary not found: {target_binary}")
# Run LibFuzzer
findings = await self._run_libfuzzer(target_binary, config, workspace)
# Create summary
summary = self._create_summary(findings)
logger.info(f"LibFuzzer found {len(findings)} issues")
return self.create_result(
findings=findings,
status="success",
summary=summary
)
except Exception as e:
logger.error(f"LibFuzzer module failed: {e}")
return self.create_result(
findings=[],
status="failed",
error=str(e)
)
async def _run_libfuzzer(self, target_binary: Path, config: Dict[str, Any], workspace: Path) -> List[ModuleFinding]:
"""Run LibFuzzer fuzzing"""
findings = []
try:
# Create output directory for artifacts
output_dir = workspace / "libfuzzer_output"
output_dir.mkdir(exist_ok=True)
# Build LibFuzzer command
cmd = [str(target_binary)]
# Add corpus directory
corpus_dir = config.get("corpus_dir")
if corpus_dir:
corpus_path = workspace / corpus_dir
if corpus_path.exists():
cmd.append(str(corpus_path))
else:
logger.warning(f"Corpus directory not found: {corpus_path}")
# Add dictionary file
dict_file = config.get("dict_file")
if dict_file:
dict_path = workspace / dict_file
if dict_path.exists():
cmd.append(f"-dict={dict_path}")
# Add fuzzing parameters
cmd.append(f"-max_total_time={config.get('max_total_time', 600)}")
cmd.append(f"-max_len={config.get('max_len', 4096)}")
cmd.append(f"-timeout={config.get('timeout', 25)}")
cmd.append(f"-runs={config.get('runs', -1)}")
if config.get("jobs", 1) > 1:
cmd.append(f"-jobs={config['jobs']}")
if config.get("workers", 1) > 1:
cmd.append(f"-workers={config['workers']}")
cmd.append(f"-reload={config.get('reload', 1)}")
# Add output options
if config.get("print_final_stats", True):
cmd.append("-print_final_stats=1")
if config.get("print_pcs", False):
cmd.append("-print_pcs=1")
if config.get("print_funcs", False):
cmd.append("-print_funcs=1")
if config.get("print_coverage", True):
cmd.append("-print_coverage=1")
# Add corpus management options
if config.get("shrink", True):
cmd.append("-shrink=1")
if config.get("reduce_inputs", True):
cmd.append("-reduce_inputs=1")
if config.get("use_value_profile", False):
cmd.append("-use_value_profile=1")
# Add artifact options
artifact_prefix = config.get("artifact_prefix", "crash-")
cmd.append(f"-artifact_prefix={output_dir / artifact_prefix}")
exact_artifact_path = config.get("exact_artifact_path")
if exact_artifact_path:
cmd.append(f"-exact_artifact_path={output_dir / exact_artifact_path}")
# Add fork mode
fork = config.get("fork", 0)
if fork > 0:
cmd.append(f"-fork={fork}")
# Add ignore options
if config.get("ignore_crashes", False):
cmd.append("-ignore_crashes=1")
if config.get("ignore_timeouts", False):
cmd.append("-ignore_timeouts=1")
if config.get("ignore_ooms", False):
cmd.append("-ignore_ooms=1")
# Set up environment for sanitizers
env = os.environ.copy()
sanitizers = config.get("sanitizers", ["address"])
self._setup_sanitizer_environment(env, sanitizers)
logger.debug(f"Running command: {' '.join(cmd)}")
# Run LibFuzzer
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=workspace,
env=env
)
stdout, stderr = await process.communicate()
# Parse results
findings = self._parse_libfuzzer_output(
stdout.decode(), stderr.decode(), output_dir, workspace, sanitizers
)
# Look for crash files
crash_findings = self._parse_crash_files(output_dir, workspace, sanitizers)
findings.extend(crash_findings)
except Exception as e:
logger.warning(f"Error running LibFuzzer: {e}")
return findings
def _setup_sanitizer_environment(self, env: Dict[str, str], sanitizers: List[str]):
"""Set up environment variables for sanitizers"""
if "address" in sanitizers:
env["ASAN_OPTIONS"] = env.get("ASAN_OPTIONS", "") + ":halt_on_error=0:abort_on_error=1"
if "memory" in sanitizers:
env["MSAN_OPTIONS"] = env.get("MSAN_OPTIONS", "") + ":halt_on_error=0:abort_on_error=1"
if "undefined" in sanitizers:
env["UBSAN_OPTIONS"] = env.get("UBSAN_OPTIONS", "") + ":halt_on_error=0:abort_on_error=1"
if "thread" in sanitizers:
env["TSAN_OPTIONS"] = env.get("TSAN_OPTIONS", "") + ":halt_on_error=0:abort_on_error=1"
if "leak" in sanitizers:
env["LSAN_OPTIONS"] = env.get("LSAN_OPTIONS", "") + ":halt_on_error=0:abort_on_error=1"
def _parse_libfuzzer_output(self, stdout: str, stderr: str, output_dir: Path, workspace: Path, sanitizers: List[str]) -> List[ModuleFinding]:
"""Parse LibFuzzer output for crashes and issues"""
findings = []
try:
# Combine stdout and stderr for analysis
full_output = stdout + "\n" + stderr
# Look for crash indicators
crash_patterns = [
r"ERROR: AddressSanitizer: (.+)",
r"ERROR: MemorySanitizer: (.+)",
r"ERROR: UndefinedBehaviorSanitizer: (.+)",
r"ERROR: ThreadSanitizer: (.+)",
r"ERROR: LeakSanitizer: (.+)",
r"SUMMARY: (.+Sanitizer): (.+)",
r"==\d+==ERROR: libFuzzer: (.+)"
]
for pattern in crash_patterns:
matches = re.finditer(pattern, full_output, re.MULTILINE)
for match in matches:
finding = self._create_crash_finding(
match, full_output, output_dir, sanitizers
)
if finding:
findings.append(finding)
# Look for timeout and OOM issues
if "TIMEOUT" in full_output:
finding = self._create_timeout_finding(full_output, output_dir)
if finding:
findings.append(finding)
if "out-of-memory" in full_output.lower() or "oom" in full_output.lower():
finding = self._create_oom_finding(full_output, output_dir)
if finding:
findings.append(finding)
except Exception as e:
logger.warning(f"Error parsing LibFuzzer output: {e}")
return findings
def _parse_crash_files(self, output_dir: Path, workspace: Path, sanitizers: List[str]) -> List[ModuleFinding]:
"""Parse crash artifact files"""
findings = []
try:
# Look for crash files
crash_patterns = ["crash-*", "leak-*", "timeout-*", "oom-*"]
for pattern in crash_patterns:
crash_files = list(output_dir.glob(pattern))
for crash_file in crash_files:
finding = self._create_artifact_finding(crash_file, workspace, sanitizers)
if finding:
findings.append(finding)
except Exception as e:
logger.warning(f"Error parsing crash files: {e}")
return findings
def _create_crash_finding(self, match, full_output: str, output_dir: Path, sanitizers: List[str]) -> ModuleFinding:
"""Create finding from crash match"""
try:
crash_type = match.group(1) if match.groups() else "Unknown crash"
# Extract stack trace
stack_trace = self._extract_stack_trace(full_output, match.start())
# Determine sanitizer
sanitizer = self._identify_sanitizer(match.group(0), sanitizers)
# Determine severity based on crash type
severity = self._get_crash_severity(crash_type, sanitizer)
# Create finding
finding = self.create_finding(
title=f"LibFuzzer Crash: {crash_type}",
description=f"LibFuzzer detected a crash with {sanitizer}: {crash_type}",
severity=severity,
category=self._get_crash_category(crash_type),
file_path=None, # LibFuzzer doesn't always provide specific files
recommendation=self._get_crash_recommendation(crash_type, sanitizer),
metadata={
"crash_type": crash_type,
"sanitizer": sanitizer,
"stack_trace": stack_trace[:2000] if stack_trace else "", # Limit size
"fuzzer": "libfuzzer"
}
)
return finding
except Exception as e:
logger.warning(f"Error creating crash finding: {e}")
return None
def _create_timeout_finding(self, output: str, output_dir: Path) -> ModuleFinding:
"""Create finding for timeout issues"""
try:
finding = self.create_finding(
title="LibFuzzer Timeout",
description="LibFuzzer detected a timeout during fuzzing, indicating potential infinite loop or performance issue",
severity="medium",
category="performance_issues",
file_path=None,
recommendation="Review the code for potential infinite loops, excessive computation, or blocking operations that could cause timeouts.",
metadata={
"issue_type": "timeout",
"fuzzer": "libfuzzer"
}
)
return finding
except Exception as e:
logger.warning(f"Error creating timeout finding: {e}")
return None
def _create_oom_finding(self, output: str, output_dir: Path) -> ModuleFinding:
"""Create finding for out-of-memory issues"""
try:
finding = self.create_finding(
title="LibFuzzer Out-of-Memory",
description="LibFuzzer detected an out-of-memory condition during fuzzing, indicating potential memory leak or excessive allocation",
severity="medium",
category="memory_management",
file_path=None,
recommendation="Review memory allocation patterns, check for memory leaks, and consider implementing proper bounds checking.",
metadata={
"issue_type": "out_of_memory",
"fuzzer": "libfuzzer"
}
)
return finding
except Exception as e:
logger.warning(f"Error creating OOM finding: {e}")
return None
def _create_artifact_finding(self, crash_file: Path, workspace: Path, sanitizers: List[str]) -> ModuleFinding:
"""Create finding from crash artifact file"""
try:
crash_type = crash_file.name.split('-')[0] # e.g., "crash", "leak", "timeout"
# Try to read crash file content (limited)
crash_content = ""
try:
crash_content = crash_file.read_bytes()[:1000].decode('utf-8', errors='ignore')
except Exception:
pass
# Determine severity
severity = self._get_artifact_severity(crash_type)
finding = self.create_finding(
title=f"LibFuzzer Artifact: {crash_type}",
description=f"LibFuzzer generated a {crash_type} artifact file indicating a potential issue",
severity=severity,
category=self._get_crash_category(crash_type),
file_path=str(crash_file.relative_to(workspace)),
recommendation=self._get_artifact_recommendation(crash_type),
metadata={
"artifact_type": crash_type,
"artifact_file": str(crash_file.name),
"crash_content_preview": crash_content,
"fuzzer": "libfuzzer"
}
)
return finding
except Exception as e:
logger.warning(f"Error creating artifact finding: {e}")
return None
def _extract_stack_trace(self, output: str, start_pos: int) -> str:
"""Extract stack trace from output"""
try:
lines = output[start_pos:].split('\n')
stack_lines = []
for line in lines[:50]: # Limit to first 50 lines
if any(indicator in line for indicator in ["#0", "#1", "#2", "at ", "in "]):
stack_lines.append(line.strip())
elif stack_lines and not line.strip():
break
return '\n'.join(stack_lines)
except Exception:
return ""
def _identify_sanitizer(self, crash_line: str, sanitizers: List[str]) -> str:
"""Identify which sanitizer detected the issue"""
crash_lower = crash_line.lower()
if "addresssanitizer" in crash_lower:
return "AddressSanitizer"
elif "memorysanitizer" in crash_lower:
return "MemorySanitizer"
elif "undefinedbehaviorsanitizer" in crash_lower:
return "UndefinedBehaviorSanitizer"
elif "threadsanitizer" in crash_lower:
return "ThreadSanitizer"
elif "leaksanitizer" in crash_lower:
return "LeakSanitizer"
elif "libfuzzer" in crash_lower:
return "LibFuzzer"
else:
return "Unknown"
def _get_crash_severity(self, crash_type: str, sanitizer: str) -> str:
"""Determine severity based on crash type and sanitizer"""
crash_lower = crash_type.lower()
# Critical issues
if any(term in crash_lower for term in ["heap-buffer-overflow", "stack-buffer-overflow", "use-after-free", "double-free"]):
return "critical"
# High severity issues
elif any(term in crash_lower for term in ["heap-use-after-free", "stack-use-after-return", "global-buffer-overflow"]):
return "high"
# Medium severity issues
elif any(term in crash_lower for term in ["uninitialized", "leak", "race", "deadlock"]):
return "medium"
# Default to high for any crash
else:
return "high"
def _get_crash_category(self, crash_type: str) -> str:
"""Determine category based on crash type"""
crash_lower = crash_type.lower()
if any(term in crash_lower for term in ["buffer-overflow", "heap-buffer", "stack-buffer", "global-buffer"]):
return "buffer_overflow"
elif any(term in crash_lower for term in ["use-after-free", "double-free", "invalid-free"]):
return "memory_corruption"
elif any(term in crash_lower for term in ["uninitialized", "uninit"]):
return "uninitialized_memory"
elif any(term in crash_lower for term in ["leak"]):
return "memory_leak"
elif any(term in crash_lower for term in ["race", "data-race"]):
return "race_condition"
elif any(term in crash_lower for term in ["timeout"]):
return "performance_issues"
elif any(term in crash_lower for term in ["oom", "out-of-memory"]):
return "memory_management"
else:
return "memory_safety"
def _get_artifact_severity(self, artifact_type: str) -> str:
"""Determine severity for artifact types"""
if artifact_type == "crash":
return "high"
elif artifact_type == "leak":
return "medium"
elif artifact_type in ["timeout", "oom"]:
return "medium"
else:
return "low"
def _get_crash_recommendation(self, crash_type: str, sanitizer: str) -> str:
"""Generate recommendation based on crash type"""
crash_lower = crash_type.lower()
if "buffer-overflow" in crash_lower:
return "Fix buffer overflow by implementing proper bounds checking, using safe string functions, and validating array indices."
elif "use-after-free" in crash_lower:
return "Fix use-after-free by setting pointers to NULL after freeing, using smart pointers, or redesigning object lifetime management."
elif "double-free" in crash_lower:
return "Fix double-free by ensuring each allocation has exactly one corresponding free, or use RAII patterns."
elif "uninitialized" in crash_lower:
return "Initialize all variables before use and ensure proper constructor implementation."
elif "leak" in crash_lower:
return "Fix memory leak by ensuring all allocated memory is properly freed, use smart pointers, or implement proper cleanup routines."
elif "race" in crash_lower:
return "Fix data race by using proper synchronization mechanisms like mutexes, atomic operations, or lock-free data structures."
else:
return f"Address the {crash_type} issue detected by {sanitizer}. Review code for memory safety and proper resource management."
def _get_artifact_recommendation(self, artifact_type: str) -> str:
"""Generate recommendation for artifact types"""
if artifact_type == "crash":
return "Analyze the crash artifact file to reproduce the issue and identify the root cause. Fix the underlying bug that caused the crash."
elif artifact_type == "leak":
return "Investigate the memory leak by analyzing allocation patterns and ensuring proper cleanup of resources."
elif artifact_type == "timeout":
return "Optimize code performance to prevent timeouts, check for infinite loops, and implement reasonable time limits."
elif artifact_type == "oom":
return "Reduce memory usage, implement proper memory management, and add bounds checking for allocations."
else:
return f"Analyze the {artifact_type} artifact to understand and fix the underlying issue."
def _create_summary(self, findings: List[ModuleFinding]) -> Dict[str, Any]:
"""Create analysis summary"""
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
category_counts = {}
sanitizer_counts = {}
crash_type_counts = {}
for finding in findings:
# Count by severity
severity_counts[finding.severity] += 1
# Count by category
category = finding.category
category_counts[category] = category_counts.get(category, 0) + 1
# Count by sanitizer
sanitizer = finding.metadata.get("sanitizer", "unknown")
sanitizer_counts[sanitizer] = sanitizer_counts.get(sanitizer, 0) + 1
# Count by crash type
crash_type = finding.metadata.get("crash_type", finding.metadata.get("issue_type", "unknown"))
crash_type_counts[crash_type] = crash_type_counts.get(crash_type, 0) + 1
return {
"total_findings": len(findings),
"severity_counts": severity_counts,
"category_counts": category_counts,
"sanitizer_counts": sanitizer_counts,
"crash_type_counts": crash_type_counts,
"memory_safety_issues": category_counts.get("memory_safety", 0) +
category_counts.get("buffer_overflow", 0) +
category_counts.get("memory_corruption", 0),
"performance_issues": category_counts.get("performance_issues", 0)
}
+547
View File
@@ -0,0 +1,547 @@
"""
OSS-Fuzz Module
This module integrates with Google's OSS-Fuzz for continuous fuzzing
of open source projects.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import asyncio
import json
import os
from pathlib import Path
from typing import Dict, Any, List
import subprocess
import logging
from ..base import BaseModule, ModuleMetadata, ModuleFinding, ModuleResult
from . import register_module
logger = logging.getLogger(__name__)
@register_module
class OSSFuzzModule(BaseModule):
"""OSS-Fuzz continuous fuzzing module"""
def get_metadata(self) -> ModuleMetadata:
"""Get module metadata"""
return ModuleMetadata(
name="oss_fuzz",
version="1.0.0",
description="Google's continuous fuzzing for open source projects integration",
author="FuzzForge Team",
category="fuzzing",
tags=["oss-fuzz", "continuous", "google", "open-source", "docker"],
input_schema={
"type": "object",
"properties": {
"project_name": {
"type": "string",
"description": "OSS-Fuzz project name"
},
"source_dir": {
"type": "string",
"description": "Source directory to fuzz"
},
"build_script": {
"type": "string",
"default": "build.sh",
"description": "Build script path"
},
"dockerfile": {
"type": "string",
"default": "Dockerfile",
"description": "Dockerfile path"
},
"project_yaml": {
"type": "string",
"default": "project.yaml",
"description": "Project configuration file"
},
"sanitizer": {
"type": "string",
"enum": ["address", "memory", "undefined", "coverage"],
"default": "address",
"description": "Sanitizer to use"
},
"architecture": {
"type": "string",
"enum": ["x86_64", "i386"],
"default": "x86_64",
"description": "Target architecture"
},
"fuzzing_engine": {
"type": "string",
"enum": ["libfuzzer", "afl", "honggfuzz"],
"default": "libfuzzer",
"description": "Fuzzing engine to use"
},
"timeout": {
"type": "integer",
"default": 3600,
"description": "Fuzzing timeout (seconds)"
},
"check_build": {
"type": "boolean",
"default": true,
"description": "Check if build is successful"
},
"reproduce_bugs": {
"type": "boolean",
"default": false,
"description": "Try to reproduce existing bugs"
}
}
},
output_schema={
"type": "object",
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"bug_type": {"type": "string"},
"reproducer": {"type": "string"},
"stack_trace": {"type": "string"},
"sanitizer": {"type": "string"}
}
}
}
}
}
)
def validate_config(self, config: Dict[str, Any]) -> bool:
"""Validate configuration"""
project_name = config.get("project_name")
if not project_name:
raise ValueError("project_name is required")
source_dir = config.get("source_dir")
if not source_dir:
raise ValueError("source_dir is required")
return True
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
"""Execute OSS-Fuzz integration"""
self.start_timer()
try:
# Validate inputs
self.validate_config(config)
self.validate_workspace(workspace)
logger.info("Running OSS-Fuzz integration")
# Check Docker
await self._check_docker()
# Clone/update OSS-Fuzz if needed
oss_fuzz_dir = await self._setup_oss_fuzz(workspace)
# Setup project
await self._setup_project(oss_fuzz_dir, config, workspace)
# Build and run
findings = await self._run_oss_fuzz(oss_fuzz_dir, config, workspace)
# Create summary
summary = self._create_summary(findings)
logger.info(f"OSS-Fuzz found {len(findings)} issues")
return self.create_result(
findings=findings,
status="success",
summary=summary
)
except Exception as e:
logger.error(f"OSS-Fuzz module failed: {e}")
return self.create_result(
findings=[],
status="failed",
error=str(e)
)
async def _check_docker(self):
"""Check if Docker is available"""
try:
process = await asyncio.create_subprocess_exec(
"docker", "--version",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
raise RuntimeError("Docker not available. OSS-Fuzz requires Docker.")
except Exception as e:
raise RuntimeError(f"Docker check failed: {e}")
async def _setup_oss_fuzz(self, workspace: Path) -> Path:
"""Setup OSS-Fuzz repository"""
oss_fuzz_dir = workspace / "oss-fuzz"
if not oss_fuzz_dir.exists():
logger.info("Cloning OSS-Fuzz repository")
process = await asyncio.create_subprocess_exec(
"git", "clone", "https://github.com/google/oss-fuzz.git",
cwd=workspace,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
raise RuntimeError(f"Failed to clone OSS-Fuzz: {stderr.decode()}")
return oss_fuzz_dir
async def _setup_project(self, oss_fuzz_dir: Path, config: Dict[str, Any], workspace: Path):
"""Setup OSS-Fuzz project"""
project_name = config["project_name"]
project_dir = oss_fuzz_dir / "projects" / project_name
# Create project directory if it doesn't exist
project_dir.mkdir(parents=True, exist_ok=True)
# Copy source if provided
source_dir = workspace / config["source_dir"]
if source_dir.exists():
# Create symlink or copy source
logger.info(f"Setting up source directory: {source_dir}")
# Setup required files if they don't exist
await self._create_project_files(project_dir, config, workspace)
async def _create_project_files(self, project_dir: Path, config: Dict[str, Any], workspace: Path):
"""Create required OSS-Fuzz project files"""
# Create Dockerfile if it doesn't exist
dockerfile = project_dir / config.get("dockerfile", "Dockerfile")
if not dockerfile.exists():
dockerfile_content = f'''FROM gcr.io/oss-fuzz-base/base-builder
COPY . $SRC/{config["project_name"]}
WORKDIR $SRC/{config["project_name"]}
COPY {config.get("build_script", "build.sh")} $SRC/
'''
dockerfile.write_text(dockerfile_content)
# Create build.sh if it doesn't exist
build_script = project_dir / config.get("build_script", "build.sh")
if not build_script.exists():
build_content = f'''#!/bin/bash -eu
# Build script for {config["project_name"]}
# Add your build commands here
echo "Building {config['project_name']}..."
'''
build_script.write_text(build_content)
build_script.chmod(0o755)
# Create project.yaml if it doesn't exist
project_yaml = project_dir / config.get("project_yaml", "project.yaml")
if not project_yaml.exists():
yaml_content = f'''homepage: "https://example.com"
language: c++
primary_contact: "security@example.com"
auto_ccs:
- "fuzzing@example.com"
sanitizers:
- {config.get("sanitizer", "address")}
architectures:
- {config.get("architecture", "x86_64")}
fuzzing_engines:
- {config.get("fuzzing_engine", "libfuzzer")}
'''
project_yaml.write_text(yaml_content)
async def _run_oss_fuzz(self, oss_fuzz_dir: Path, config: Dict[str, Any], workspace: Path) -> List[ModuleFinding]:
"""Run OSS-Fuzz"""
findings = []
try:
project_name = config["project_name"]
sanitizer = config.get("sanitizer", "address")
architecture = config.get("architecture", "x86_64")
# Build project
if config.get("check_build", True):
await self._build_project(oss_fuzz_dir, project_name, sanitizer, architecture)
# Check build
await self._check_build(oss_fuzz_dir, project_name, sanitizer, architecture)
# Run fuzzing (limited time for this integration)
timeout = min(config.get("timeout", 300), 300) # Max 5 minutes for demo
findings = await self._run_fuzzing(oss_fuzz_dir, project_name, sanitizer, timeout, workspace)
# Reproduce bugs if requested
if config.get("reproduce_bugs", False):
repro_findings = await self._reproduce_bugs(oss_fuzz_dir, project_name, workspace)
findings.extend(repro_findings)
except Exception as e:
logger.warning(f"Error running OSS-Fuzz: {e}")
return findings
async def _build_project(self, oss_fuzz_dir: Path, project_name: str, sanitizer: str, architecture: str):
"""Build OSS-Fuzz project"""
cmd = [
"python3", "infra/helper.py", "build_image", project_name
]
process = await asyncio.create_subprocess_exec(
*cmd,
cwd=oss_fuzz_dir,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
logger.warning(f"Build image failed: {stderr.decode()}")
async def _check_build(self, oss_fuzz_dir: Path, project_name: str, sanitizer: str, architecture: str):
"""Check OSS-Fuzz build"""
cmd = [
"python3", "infra/helper.py", "check_build", project_name
]
process = await asyncio.create_subprocess_exec(
*cmd,
cwd=oss_fuzz_dir,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
logger.warning(f"Build check failed: {stderr.decode()}")
async def _run_fuzzing(self, oss_fuzz_dir: Path, project_name: str, sanitizer: str, timeout: int, workspace: Path) -> List[ModuleFinding]:
"""Run OSS-Fuzz fuzzing"""
findings = []
try:
# This is a simplified version - real OSS-Fuzz runs for much longer
cmd = [
"python3", "infra/helper.py", "run_fuzzer", project_name,
"--", f"-max_total_time={timeout}"
]
process = await asyncio.create_subprocess_exec(
*cmd,
cwd=oss_fuzz_dir,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(), timeout=timeout + 60
)
except asyncio.TimeoutError:
process.terminate()
await process.wait()
# Parse output for crashes
full_output = stdout.decode() + stderr.decode()
findings = self._parse_oss_fuzz_output(full_output, workspace, sanitizer)
except Exception as e:
logger.warning(f"Error in OSS-Fuzz execution: {e}")
return findings
async def _reproduce_bugs(self, oss_fuzz_dir: Path, project_name: str, workspace: Path) -> List[ModuleFinding]:
"""Reproduce existing bugs"""
findings = []
try:
# Look for existing testcases or artifacts
testcases_dir = oss_fuzz_dir / "projects" / project_name / "testcases"
if testcases_dir.exists():
for testcase in testcases_dir.iterdir():
if testcase.is_file():
finding = self._create_testcase_finding(testcase, workspace)
if finding:
findings.append(finding)
except Exception as e:
logger.warning(f"Error reproducing bugs: {e}")
return findings
def _parse_oss_fuzz_output(self, output: str, workspace: Path, sanitizer: str) -> List[ModuleFinding]:
"""Parse OSS-Fuzz output"""
findings = []
try:
# Look for common crash indicators
lines = output.split('\n')
crash_info = None
for line in lines:
if "ERROR:" in line and any(term in line for term in ["AddressSanitizer", "MemorySanitizer", "UBSan"]):
crash_info = {
"type": self._extract_crash_type(line),
"sanitizer": sanitizer,
"line": line
}
elif crash_info and line.strip().startswith("#"):
# Stack trace line
if "stack_trace" not in crash_info:
crash_info["stack_trace"] = []
crash_info["stack_trace"].append(line.strip())
if crash_info:
finding = self._create_oss_fuzz_finding(crash_info, workspace)
if finding:
findings.append(finding)
except Exception as e:
logger.warning(f"Error parsing OSS-Fuzz output: {e}")
return findings
def _create_oss_fuzz_finding(self, crash_info: Dict[str, Any], workspace: Path) -> ModuleFinding:
"""Create finding from OSS-Fuzz crash"""
try:
bug_type = crash_info.get("type", "unknown")
sanitizer = crash_info.get("sanitizer", "unknown")
stack_trace = '\n'.join(crash_info.get("stack_trace", [])[:20])
severity = self._get_oss_fuzz_severity(bug_type)
finding = self.create_finding(
title=f"OSS-Fuzz {bug_type.title()}",
description=f"OSS-Fuzz detected a {bug_type} using {sanitizer} sanitizer",
severity=severity,
category=self._get_oss_fuzz_category(bug_type),
file_path=None,
recommendation=self._get_oss_fuzz_recommendation(bug_type, sanitizer),
metadata={
"bug_type": bug_type,
"sanitizer": sanitizer,
"stack_trace": stack_trace,
"fuzzer": "oss_fuzz"
}
)
return finding
except Exception as e:
logger.warning(f"Error creating OSS-Fuzz finding: {e}")
return None
def _create_testcase_finding(self, testcase_file: Path, workspace: Path) -> ModuleFinding:
"""Create finding from testcase file"""
try:
try:
rel_path = testcase_file.relative_to(workspace)
file_path = str(rel_path)
except ValueError:
file_path = str(testcase_file)
finding = self.create_finding(
title="OSS-Fuzz Testcase",
description=f"OSS-Fuzz testcase found: {testcase_file.name}",
severity="info",
category="testcase",
file_path=file_path,
recommendation="Analyze testcase to understand potential issues",
metadata={
"testcase_file": str(testcase_file),
"fuzzer": "oss_fuzz"
}
)
return finding
except Exception as e:
logger.warning(f"Error creating testcase finding: {e}")
return None
def _extract_crash_type(self, line: str) -> str:
"""Extract crash type from error line"""
if "heap-buffer-overflow" in line:
return "heap_buffer_overflow"
elif "stack-buffer-overflow" in line:
return "stack_buffer_overflow"
elif "use-after-free" in line:
return "use_after_free"
elif "double-free" in line:
return "double_free"
elif "memory leak" in line:
return "memory_leak"
else:
return "unknown_crash"
def _get_oss_fuzz_severity(self, bug_type: str) -> str:
"""Get severity for OSS-Fuzz bug type"""
if bug_type in ["heap_buffer_overflow", "stack_buffer_overflow", "use_after_free", "double_free"]:
return "critical"
elif bug_type == "memory_leak":
return "medium"
else:
return "high"
def _get_oss_fuzz_category(self, bug_type: str) -> str:
"""Get category for OSS-Fuzz bug type"""
if "overflow" in bug_type:
return "buffer_overflow"
elif "free" in bug_type:
return "memory_corruption"
elif "leak" in bug_type:
return "memory_leak"
else:
return "memory_safety"
def _get_oss_fuzz_recommendation(self, bug_type: str, sanitizer: str) -> str:
"""Get recommendation for OSS-Fuzz finding"""
if "overflow" in bug_type:
return "Fix buffer overflow by implementing proper bounds checking and using safe string functions."
elif "use_after_free" in bug_type:
return "Fix use-after-free by ensuring proper object lifetime management and setting pointers to NULL after freeing."
elif "double_free" in bug_type:
return "Fix double-free by ensuring each allocation has exactly one corresponding free operation."
elif "leak" in bug_type:
return "Fix memory leak by ensuring all allocated memory is properly freed in all code paths."
else:
return f"Address the {bug_type} issue detected by OSS-Fuzz with {sanitizer} sanitizer."
def _create_summary(self, findings: List[ModuleFinding]) -> Dict[str, Any]:
"""Create analysis summary"""
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
category_counts = {}
sanitizer_counts = {}
for finding in findings:
severity_counts[finding.severity] += 1
category_counts[finding.category] = category_counts.get(finding.category, 0) + 1
sanitizer = finding.metadata.get("sanitizer", "unknown")
sanitizer_counts[sanitizer] = sanitizer_counts.get(sanitizer, 0) + 1
return {
"total_findings": len(findings),
"severity_counts": severity_counts,
"category_counts": category_counts,
"sanitizer_counts": sanitizer_counts
}
@@ -0,0 +1,43 @@
"""
Infrastructure Security Modules
This package contains modules for Infrastructure as Code (IaC) security testing.
Available modules:
- Checkov: Terraform/CloudFormation/Kubernetes IaC security
- Hadolint: Dockerfile security linting and best practices
- Kubesec: Kubernetes security risk analysis
- Polaris: Kubernetes configuration validation
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
from typing import List, Type
from ..base import BaseModule
# Module registry for automatic discovery
INFRASTRUCTURE_MODULES: List[Type[BaseModule]] = []
def register_module(module_class: Type[BaseModule]):
"""Register an infrastructure security module"""
INFRASTRUCTURE_MODULES.append(module_class)
return module_class
def get_available_modules() -> List[Type[BaseModule]]:
"""Get all available infrastructure security modules"""
return INFRASTRUCTURE_MODULES.copy()
# Import modules to trigger registration
from .checkov import CheckovModule
from .hadolint import HadolintModule
from .kubesec import KubesecModule
from .polaris import PolarisModule
@@ -0,0 +1,411 @@
"""
Checkov Infrastructure Security Module
This module uses Checkov to scan Infrastructure as Code (IaC) files for
security misconfigurations and compliance violations.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import asyncio
import json
from pathlib import Path
from typing import Dict, Any, List
import subprocess
import logging
from ..base import BaseModule, ModuleMetadata, ModuleFinding, ModuleResult
from . import register_module
logger = logging.getLogger(__name__)
@register_module
class CheckovModule(BaseModule):
"""Checkov Infrastructure as Code security scanning module"""
def get_metadata(self) -> ModuleMetadata:
"""Get module metadata"""
return ModuleMetadata(
name="checkov",
version="3.1.34",
description="Infrastructure as Code security scanning for Terraform, CloudFormation, Kubernetes, and more",
author="FuzzForge Team",
category="infrastructure",
tags=["iac", "terraform", "cloudformation", "kubernetes", "security", "compliance"],
input_schema={
"type": "object",
"properties": {
"frameworks": {
"type": "array",
"items": {"type": "string"},
"default": ["terraform", "cloudformation", "kubernetes"],
"description": "IaC frameworks to scan"
},
"checks": {
"type": "array",
"items": {"type": "string"},
"description": "Specific checks to run"
},
"skip_checks": {
"type": "array",
"items": {"type": "string"},
"description": "Checks to skip"
},
"severity": {
"type": "array",
"items": {"type": "string", "enum": ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"]},
"default": ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"],
"description": "Minimum severity levels to report"
},
"compact": {
"type": "boolean",
"default": False,
"description": "Use compact output format"
},
"quiet": {
"type": "boolean",
"default": False,
"description": "Suppress verbose output"
},
"soft_fail": {
"type": "boolean",
"default": True,
"description": "Return exit code 0 even when issues are found"
},
"include_patterns": {
"type": "array",
"items": {"type": "string"},
"description": "File patterns to include"
},
"exclude_patterns": {
"type": "array",
"items": {"type": "string"},
"description": "File patterns to exclude"
}
}
},
output_schema={
"type": "object",
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"check_id": {"type": "string"},
"check_name": {"type": "string"},
"severity": {"type": "string"},
"file_path": {"type": "string"},
"line_range": {"type": "array"}
}
}
}
}
}
)
def validate_config(self, config: Dict[str, Any]) -> bool:
"""Validate configuration"""
frameworks = config.get("frameworks", [])
supported_frameworks = [
"terraform", "cloudformation", "kubernetes", "dockerfile",
"ansible", "helm", "serverless", "bicep", "github_actions"
]
for framework in frameworks:
if framework not in supported_frameworks:
raise ValueError(f"Unsupported framework: {framework}. Supported: {supported_frameworks}")
return True
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
"""Execute Checkov IaC security scanning"""
self.start_timer()
try:
# Validate inputs
self.validate_config(config)
self.validate_workspace(workspace)
logger.info(f"Running Checkov IaC scan on {workspace}")
# Check if there are any IaC files
iac_files = self._find_iac_files(workspace, config.get("frameworks", []))
if not iac_files:
logger.info("No Infrastructure as Code files found")
return self.create_result(
findings=[],
status="success",
summary={"total_findings": 0, "files_scanned": 0}
)
# Build checkov command
cmd = ["checkov", "-d", str(workspace)]
# Add output format
cmd.extend(["--output", "json"])
# Add frameworks
frameworks = config.get("frameworks", ["terraform", "cloudformation", "kubernetes"])
cmd.extend(["--framework"] + frameworks)
# Add specific checks
if config.get("checks"):
cmd.extend(["--check", ",".join(config["checks"])])
# Add skip checks
if config.get("skip_checks"):
cmd.extend(["--skip-check", ",".join(config["skip_checks"])])
# Add compact flag
if config.get("compact", False):
cmd.append("--compact")
# Add quiet flag
if config.get("quiet", False):
cmd.append("--quiet")
# Add soft fail
if config.get("soft_fail", True):
cmd.append("--soft-fail")
# Add include patterns
if config.get("include_patterns"):
for pattern in config["include_patterns"]:
cmd.extend(["--include", pattern])
# Add exclude patterns
if config.get("exclude_patterns"):
for pattern in config["exclude_patterns"]:
cmd.extend(["--exclude", pattern])
# Disable update checks and telemetry
cmd.extend(["--no-guide", "--skip-download"])
logger.debug(f"Running command: {' '.join(cmd)}")
# Run Checkov
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=workspace
)
stdout, stderr = await process.communicate()
# Parse results
findings = []
if process.returncode == 0 or config.get("soft_fail", True):
findings = self._parse_checkov_output(stdout.decode(), workspace, config)
else:
error_msg = stderr.decode()
logger.error(f"Checkov failed: {error_msg}")
return self.create_result(
findings=[],
status="failed",
error=f"Checkov execution failed: {error_msg}"
)
# Create summary
summary = self._create_summary(findings, len(iac_files))
logger.info(f"Checkov found {len(findings)} security issues")
return self.create_result(
findings=findings,
status="success",
summary=summary
)
except Exception as e:
logger.error(f"Checkov module failed: {e}")
return self.create_result(
findings=[],
status="failed",
error=str(e)
)
def _find_iac_files(self, workspace: Path, frameworks: List[str]) -> List[Path]:
"""Find Infrastructure as Code files in workspace"""
iac_patterns = {
"terraform": ["*.tf", "*.tfvars"],
"cloudformation": ["*.yaml", "*.yml", "*.json", "*template*"],
"kubernetes": ["*.yaml", "*.yml"],
"dockerfile": ["Dockerfile", "*.dockerfile"],
"ansible": ["*.yaml", "*.yml", "playbook*"],
"helm": ["Chart.yaml", "values.yaml", "*.yaml"],
"bicep": ["*.bicep"],
"github_actions": [".github/workflows/*.yaml", ".github/workflows/*.yml"]
}
found_files = []
for framework in frameworks:
patterns = iac_patterns.get(framework, [])
for pattern in patterns:
found_files.extend(workspace.rglob(pattern))
return list(set(found_files)) # Remove duplicates
def _parse_checkov_output(self, output: str, workspace: Path, config: Dict[str, Any]) -> List[ModuleFinding]:
"""Parse Checkov JSON output into findings"""
findings = []
if not output.strip():
return findings
try:
data = json.loads(output)
# Get severity filter
allowed_severities = set(s.upper() for s in config.get("severity", ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"]))
# Process failed checks
failed_checks = data.get("results", {}).get("failed_checks", [])
for check in failed_checks:
# Extract information
check_id = check.get("check_id", "unknown")
check_name = check.get("check_name", "")
severity = check.get("severity", "MEDIUM").upper()
file_path = check.get("file_path", "")
file_line_range = check.get("file_line_range", [])
resource = check.get("resource", "")
description = check.get("description", "")
guideline = check.get("guideline", "")
# Apply severity filter
if severity not in allowed_severities:
continue
# Make file path relative to workspace
if file_path:
try:
rel_path = Path(file_path).relative_to(workspace)
file_path = str(rel_path)
except ValueError:
pass
# Map severity to our standard levels
finding_severity = self._map_severity(severity)
# Create finding
finding = self.create_finding(
title=f"IaC Security Issue: {check_name}",
description=description or f"Checkov check {check_id} failed for resource {resource}",
severity=finding_severity,
category=self._get_category(check_id, check_name),
file_path=file_path if file_path else None,
line_start=file_line_range[0] if file_line_range and len(file_line_range) > 0 else None,
line_end=file_line_range[1] if file_line_range and len(file_line_range) > 1 else None,
recommendation=self._get_recommendation(check_id, check_name, guideline),
metadata={
"check_id": check_id,
"check_name": check_name,
"checkov_severity": severity,
"resource": resource,
"guideline": guideline,
"bc_category": check.get("bc_category", ""),
"benchmarks": check.get("benchmarks", {}),
"fixed_definition": check.get("fixed_definition", "")
}
)
findings.append(finding)
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse Checkov output: {e}")
except Exception as e:
logger.warning(f"Error processing Checkov results: {e}")
return findings
def _map_severity(self, checkov_severity: str) -> str:
"""Map Checkov severity to our standard severity levels"""
severity_map = {
"CRITICAL": "critical",
"HIGH": "high",
"MEDIUM": "medium",
"LOW": "low",
"INFO": "info"
}
return severity_map.get(checkov_severity.upper(), "medium")
def _get_category(self, check_id: str, check_name: str) -> str:
"""Determine finding category based on check"""
check_lower = f"{check_id} {check_name}".lower()
if any(term in check_lower for term in ["encryption", "encrypt", "kms", "ssl", "tls"]):
return "encryption"
elif any(term in check_lower for term in ["access", "iam", "rbac", "permission"]):
return "access_control"
elif any(term in check_lower for term in ["network", "security group", "firewall", "vpc"]):
return "network_security"
elif any(term in check_lower for term in ["logging", "monitor", "audit"]):
return "logging_monitoring"
elif any(term in check_lower for term in ["storage", "s3", "bucket", "database"]):
return "data_protection"
elif any(term in check_lower for term in ["secret", "password", "key", "credential"]):
return "secrets_management"
elif any(term in check_lower for term in ["backup", "snapshot", "versioning"]):
return "backup_recovery"
else:
return "infrastructure_security"
def _get_recommendation(self, check_id: str, check_name: str, guideline: str) -> str:
"""Generate recommendation based on check"""
if guideline:
return f"Follow the guideline: {guideline}"
# Generic recommendations based on common patterns
check_lower = f"{check_id} {check_name}".lower()
if "encryption" in check_lower:
return "Enable encryption for sensitive data at rest and in transit using appropriate encryption algorithms."
elif "access" in check_lower or "iam" in check_lower:
return "Review and tighten access controls. Follow the principle of least privilege."
elif "network" in check_lower or "security group" in check_lower:
return "Restrict network access to only necessary ports and IP ranges."
elif "logging" in check_lower:
return "Enable comprehensive logging and monitoring for security events."
elif "backup" in check_lower:
return "Implement proper backup and disaster recovery procedures."
else:
return f"Review and fix the security configuration issue identified by check {check_id}."
def _create_summary(self, findings: List[ModuleFinding], total_files: int) -> Dict[str, Any]:
"""Create analysis summary"""
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
category_counts = {}
check_counts = {}
for finding in findings:
# Count by severity
severity_counts[finding.severity] += 1
# Count by category
category = finding.category
category_counts[category] = category_counts.get(category, 0) + 1
# Count by check
check_id = finding.metadata.get("check_id", "unknown")
check_counts[check_id] = check_counts.get(check_id, 0) + 1
return {
"total_findings": len(findings),
"files_scanned": total_files,
"severity_counts": severity_counts,
"category_counts": category_counts,
"top_checks": dict(sorted(check_counts.items(), key=lambda x: x[1], reverse=True)[:10]),
"files_with_issues": len(set(f.file_path for f in findings if f.file_path))
}
@@ -0,0 +1,406 @@
"""
Hadolint Infrastructure Security Module
This module uses Hadolint to scan Dockerfiles for security best practices
and potential vulnerabilities.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import asyncio
import json
from pathlib import Path
from typing import Dict, Any, List
import subprocess
import logging
from ..base import BaseModule, ModuleMetadata, ModuleFinding, ModuleResult
from . import register_module
logger = logging.getLogger(__name__)
@register_module
class HadolintModule(BaseModule):
"""Hadolint Dockerfile security scanning module"""
def get_metadata(self) -> ModuleMetadata:
"""Get module metadata"""
return ModuleMetadata(
name="hadolint",
version="2.12.0",
description="Dockerfile security linting and best practices validation",
author="FuzzForge Team",
category="infrastructure",
tags=["dockerfile", "docker", "security", "best-practices", "linting"],
input_schema={
"type": "object",
"properties": {
"severity": {
"type": "array",
"items": {"type": "string", "enum": ["error", "warning", "info", "style"]},
"default": ["error", "warning", "info", "style"],
"description": "Minimum severity levels to report"
},
"ignored_rules": {
"type": "array",
"items": {"type": "string"},
"description": "Hadolint rules to ignore"
},
"trusted_registries": {
"type": "array",
"items": {"type": "string"},
"description": "List of trusted Docker registries"
},
"allowed_maintainers": {
"type": "array",
"items": {"type": "string"},
"description": "List of allowed maintainer emails"
},
"dockerfile_patterns": {
"type": "array",
"items": {"type": "string"},
"default": ["**/Dockerfile", "**/*.dockerfile", "**/Containerfile"],
"description": "Patterns to find Dockerfile-like files"
},
"strict": {
"type": "boolean",
"default": False,
"description": "Enable strict mode (fail on any issue)"
},
"no_fail": {
"type": "boolean",
"default": True,
"description": "Don't fail on lint errors (useful for reporting)"
}
}
},
output_schema={
"type": "object",
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"rule": {"type": "string"},
"severity": {"type": "string"},
"message": {"type": "string"},
"file_path": {"type": "string"},
"line": {"type": "integer"}
}
}
}
}
}
)
def validate_config(self, config: Dict[str, Any]) -> bool:
"""Validate configuration"""
severity_levels = config.get("severity", ["error", "warning", "info", "style"])
valid_severities = ["error", "warning", "info", "style"]
for severity in severity_levels:
if severity not in valid_severities:
raise ValueError(f"Invalid severity level: {severity}. Valid: {valid_severities}")
return True
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
"""Execute Hadolint Dockerfile security scanning"""
self.start_timer()
try:
# Validate inputs
self.validate_config(config)
self.validate_workspace(workspace)
logger.info(f"Running Hadolint Dockerfile scan on {workspace}")
# Find all Dockerfiles
dockerfiles = self._find_dockerfiles(workspace, config)
if not dockerfiles:
logger.info("No Dockerfiles found for Hadolint analysis")
return self.create_result(
findings=[],
status="success",
summary={"total_findings": 0, "files_scanned": 0}
)
logger.info(f"Found {len(dockerfiles)} Dockerfile(s) to analyze")
# Process each Dockerfile
all_findings = []
for dockerfile in dockerfiles:
findings = await self._scan_dockerfile(dockerfile, workspace, config)
all_findings.extend(findings)
# Create summary
summary = self._create_summary(all_findings, len(dockerfiles))
logger.info(f"Hadolint found {len(all_findings)} issues across {len(dockerfiles)} Dockerfiles")
return self.create_result(
findings=all_findings,
status="success",
summary=summary
)
except Exception as e:
logger.error(f"Hadolint module failed: {e}")
return self.create_result(
findings=[],
status="failed",
error=str(e)
)
def _find_dockerfiles(self, workspace: Path, config: Dict[str, Any]) -> List[Path]:
"""Find Dockerfile-like files in workspace"""
patterns = config.get("dockerfile_patterns", [
"**/Dockerfile", "**/*.dockerfile", "**/Containerfile"
])
# Debug logging
logger.info(f"Hadolint searching in workspace: {workspace}")
logger.info(f"Workspace exists: {workspace.exists()}")
if workspace.exists():
all_files = list(workspace.rglob("*"))
logger.info(f"All files in workspace: {all_files}")
dockerfiles = []
for pattern in patterns:
matches = list(workspace.glob(pattern))
logger.info(f"Pattern '{pattern}' found: {matches}")
dockerfiles.extend(matches)
logger.info(f"Final dockerfiles list: {dockerfiles}")
return list(set(dockerfiles)) # Remove duplicates
async def _scan_dockerfile(self, dockerfile: Path, workspace: Path, config: Dict[str, Any]) -> List[ModuleFinding]:
"""Scan a single Dockerfile with Hadolint"""
findings = []
try:
# Build hadolint command
cmd = ["hadolint", "--format", "json"]
# Add severity levels
severity_levels = config.get("severity", ["error", "warning", "info", "style"])
if "error" not in severity_levels:
cmd.append("--no-error")
if "warning" not in severity_levels:
cmd.append("--no-warning")
if "info" not in severity_levels:
cmd.append("--no-info")
if "style" not in severity_levels:
cmd.append("--no-style")
# Add ignored rules
ignored_rules = config.get("ignored_rules", [])
for rule in ignored_rules:
cmd.extend(["--ignore", rule])
# Add trusted registries
trusted_registries = config.get("trusted_registries", [])
for registry in trusted_registries:
cmd.extend(["--trusted-registry", registry])
# Add strict mode
if config.get("strict", False):
cmd.append("--strict-labels")
# Add the dockerfile
cmd.append(str(dockerfile))
logger.debug(f"Running command: {' '.join(cmd)}")
# Run hadolint
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=workspace
)
stdout, stderr = await process.communicate()
# Parse results
if process.returncode == 0 or config.get("no_fail", True):
findings = self._parse_hadolint_output(
stdout.decode(), dockerfile, workspace
)
else:
error_msg = stderr.decode()
logger.warning(f"Hadolint failed for {dockerfile}: {error_msg}")
# Continue with other files even if one fails
except Exception as e:
logger.warning(f"Error scanning {dockerfile}: {e}")
return findings
def _parse_hadolint_output(self, output: str, dockerfile: Path, workspace: Path) -> List[ModuleFinding]:
"""Parse Hadolint JSON output into findings"""
findings = []
if not output.strip():
return findings
try:
# Hadolint outputs JSON array
issues = json.loads(output)
for issue in issues:
# Extract information
rule = issue.get("code", "unknown")
message = issue.get("message", "")
level = issue.get("level", "warning").lower()
line = issue.get("line", 0)
column = issue.get("column", 0)
# Make file path relative to workspace
try:
rel_path = dockerfile.relative_to(workspace)
file_path = str(rel_path)
except ValueError:
file_path = str(dockerfile)
# Map Hadolint level to our severity
severity = self._map_severity(level)
# Get category based on rule
category = self._get_category(rule, message)
# Create finding
finding = self.create_finding(
title=f"Dockerfile issue: {rule}",
description=message or f"Hadolint rule {rule} violation",
severity=severity,
category=category,
file_path=file_path,
line_start=line if line > 0 else None,
recommendation=self._get_recommendation(rule, message),
metadata={
"rule": rule,
"hadolint_level": level,
"column": column,
"file": str(dockerfile)
}
)
findings.append(finding)
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse Hadolint output: {e}")
except Exception as e:
logger.warning(f"Error processing Hadolint results: {e}")
return findings
def _map_severity(self, hadolint_level: str) -> str:
"""Map Hadolint severity to our standard severity levels"""
severity_map = {
"error": "high",
"warning": "medium",
"info": "low",
"style": "info"
}
return severity_map.get(hadolint_level.lower(), "medium")
def _get_category(self, rule: str, message: str) -> str:
"""Determine finding category based on rule and message"""
rule_lower = rule.lower()
message_lower = message.lower()
# Security-related categories
if any(term in rule_lower for term in ["dl3", "dl4"]):
if "user" in message_lower or "root" in message_lower:
return "privilege_escalation"
elif "secret" in message_lower or "password" in message_lower:
return "secrets_management"
elif "version" in message_lower or "pin" in message_lower:
return "dependency_management"
elif "add" in message_lower or "copy" in message_lower:
return "file_operations"
else:
return "security_best_practices"
elif any(term in rule_lower for term in ["dl1", "dl2"]):
return "syntax_errors"
elif "3001" in rule or "3002" in rule:
return "user_management"
elif "3008" in rule or "3009" in rule:
return "privilege_escalation"
elif "3014" in rule or "3015" in rule:
return "port_management"
elif "3020" in rule or "3021" in rule:
return "copy_operations"
else:
return "dockerfile_best_practices"
def _get_recommendation(self, rule: str, message: str) -> str:
"""Generate recommendation based on Hadolint rule"""
recommendations = {
# Security-focused recommendations
"DL3002": "Create a non-root user and switch to it before running the application.",
"DL3008": "Pin package versions to ensure reproducible builds and avoid supply chain attacks.",
"DL3009": "Clean up package manager cache after installation to reduce image size and attack surface.",
"DL3020": "Use COPY instead of ADD for local files to avoid unexpected behavior.",
"DL3025": "Use JSON format for CMD and ENTRYPOINT to avoid shell injection vulnerabilities.",
"DL3059": "Use multi-stage builds to reduce final image size and attack surface.",
"DL4001": "Don't use sudo in Dockerfiles as it's unnecessary and can introduce vulnerabilities.",
"DL4003": "Use a package manager instead of downloading and installing manually.",
"DL4004": "Don't use SSH in Dockerfiles as it's a security risk.",
"DL4005": "Use SHELL instruction to specify shell for RUN commands instead of hardcoding paths.",
}
if rule in recommendations:
return recommendations[rule]
# Generic recommendations based on patterns
message_lower = message.lower()
if "user" in message_lower and "root" in message_lower:
return "Avoid running containers as root user. Create and use a non-privileged user."
elif "version" in message_lower or "pin" in message_lower:
return "Pin package versions to specific versions to ensure reproducible builds."
elif "cache" in message_lower or "clean" in message_lower:
return "Clean up package manager caches to reduce image size and potential security issues."
elif "secret" in message_lower or "password" in message_lower:
return "Don't include secrets in Dockerfiles. Use build arguments or runtime secrets instead."
else:
return f"Follow Dockerfile best practices to address rule {rule}."
def _create_summary(self, findings: List[ModuleFinding], total_files: int) -> Dict[str, Any]:
"""Create analysis summary"""
severity_counts = {"high": 0, "medium": 0, "low": 0, "info": 0}
category_counts = {}
rule_counts = {}
for finding in findings:
# Count by severity
severity_counts[finding.severity] += 1
# Count by category
category = finding.category
category_counts[category] = category_counts.get(category, 0) + 1
# Count by rule
rule = finding.metadata.get("rule", "unknown")
rule_counts[rule] = rule_counts.get(rule, 0) + 1
return {
"total_findings": len(findings),
"files_scanned": total_files,
"severity_counts": severity_counts,
"category_counts": category_counts,
"top_rules": dict(sorted(rule_counts.items(), key=lambda x: x[1], reverse=True)[:10]),
"files_with_issues": len(set(f.file_path for f in findings if f.file_path))
}
@@ -0,0 +1,447 @@
"""
Kubesec Infrastructure Security Module
This module uses Kubesec to scan Kubernetes manifests for security
misconfigurations and best practices violations.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import asyncio
import json
from pathlib import Path
from typing import Dict, Any, List
import subprocess
import logging
from ..base import BaseModule, ModuleMetadata, ModuleFinding, ModuleResult
from . import register_module
logger = logging.getLogger(__name__)
@register_module
class KubesecModule(BaseModule):
"""Kubesec Kubernetes security scanning module"""
def get_metadata(self) -> ModuleMetadata:
"""Get module metadata"""
return ModuleMetadata(
name="kubesec",
version="2.14.0",
description="Kubernetes security scanning for YAML/JSON manifests with security best practices validation",
author="FuzzForge Team",
category="infrastructure",
tags=["kubernetes", "k8s", "security", "best-practices", "manifests"],
input_schema={
"type": "object",
"properties": {
"scan_mode": {
"type": "string",
"enum": ["scan", "http"],
"default": "scan",
"description": "Kubesec scan mode (local scan or HTTP API)"
},
"threshold": {
"type": "integer",
"default": 15,
"description": "Minimum security score threshold"
},
"exit_code": {
"type": "integer",
"default": 0,
"description": "Exit code to return on failure"
},
"format": {
"type": "string",
"enum": ["json", "template"],
"default": "json",
"description": "Output format"
},
"kubernetes_patterns": {
"type": "array",
"items": {"type": "string"},
"default": ["**/*.yaml", "**/*.yml", "**/k8s/*.yaml", "**/kubernetes/*.yaml"],
"description": "Patterns to find Kubernetes manifest files"
},
"exclude_patterns": {
"type": "array",
"items": {"type": "string"},
"description": "Patterns to exclude from scanning"
},
"strict": {
"type": "boolean",
"default": False,
"description": "Enable strict mode (fail on any security issue)"
}
}
},
output_schema={
"type": "object",
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"score": {"type": "integer"},
"security_issues": {"type": "array"},
"file_path": {"type": "string"},
"manifest_kind": {"type": "string"}
}
}
}
}
}
)
def validate_config(self, config: Dict[str, Any]) -> bool:
"""Validate configuration"""
scan_mode = config.get("scan_mode", "scan")
if scan_mode not in ["scan", "http"]:
raise ValueError(f"Invalid scan mode: {scan_mode}. Valid: ['scan', 'http']")
threshold = config.get("threshold", 0)
if not isinstance(threshold, int):
raise ValueError(f"Threshold must be an integer, got: {type(threshold)}")
return True
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
"""Execute Kubesec Kubernetes security scanning"""
self.start_timer()
try:
# Validate inputs
self.validate_config(config)
self.validate_workspace(workspace)
logger.info(f"Running Kubesec Kubernetes scan on {workspace}")
# Find all Kubernetes manifests
k8s_files = self._find_kubernetes_files(workspace, config)
if not k8s_files:
logger.info("No Kubernetes manifest files found")
return self.create_result(
findings=[],
status="success",
summary={"total_findings": 0, "files_scanned": 0}
)
logger.info(f"Found {len(k8s_files)} Kubernetes manifest file(s) to analyze")
# Process each manifest file
all_findings = []
for k8s_file in k8s_files:
findings = await self._scan_manifest(k8s_file, workspace, config)
all_findings.extend(findings)
# Create summary
summary = self._create_summary(all_findings, len(k8s_files))
logger.info(f"Kubesec found {len(all_findings)} security issues across {len(k8s_files)} manifests")
return self.create_result(
findings=all_findings,
status="success",
summary=summary
)
except Exception as e:
logger.error(f"Kubesec module failed: {e}")
return self.create_result(
findings=[],
status="failed",
error=str(e)
)
def _find_kubernetes_files(self, workspace: Path, config: Dict[str, Any]) -> List[Path]:
"""Find Kubernetes manifest files in workspace"""
patterns = config.get("kubernetes_patterns", [
"**/*.yaml", "**/*.yml", "**/k8s/*.yaml", "**/kubernetes/*.yaml"
])
exclude_patterns = config.get("exclude_patterns", [])
k8s_files = []
for pattern in patterns:
files = workspace.glob(pattern)
for file in files:
# Check if file contains Kubernetes resources
if self._is_kubernetes_manifest(file):
# Check if file should be excluded
should_exclude = False
for exclude_pattern in exclude_patterns:
if file.match(exclude_pattern):
should_exclude = True
break
if not should_exclude:
k8s_files.append(file)
return list(set(k8s_files)) # Remove duplicates
def _is_kubernetes_manifest(self, file: Path) -> bool:
"""Check if a file is a Kubernetes manifest"""
try:
content = file.read_text(encoding='utf-8')
# Simple heuristic: check for common Kubernetes fields
k8s_indicators = [
"apiVersion:", "kind:", "metadata:", "spec:",
"Deployment", "Service", "Pod", "ConfigMap",
"Secret", "Ingress", "PersistentVolume"
]
return any(indicator in content for indicator in k8s_indicators)
except Exception:
return False
async def _scan_manifest(self, manifest_file: Path, workspace: Path, config: Dict[str, Any]) -> List[ModuleFinding]:
"""Scan a single Kubernetes manifest with Kubesec"""
findings = []
try:
# Build kubesec command
cmd = ["kubesec", "scan"]
# Add format
format_type = config.get("format", "json")
if format_type == "json":
cmd.append("-f")
cmd.append("json")
# Add the manifest file
cmd.append(str(manifest_file))
logger.debug(f"Running command: {' '.join(cmd)}")
# Run kubesec
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=workspace
)
stdout, stderr = await process.communicate()
# Parse results
if process.returncode == 0:
findings = self._parse_kubesec_output(
stdout.decode(), manifest_file, workspace, config
)
else:
error_msg = stderr.decode()
logger.warning(f"Kubesec failed for {manifest_file}: {error_msg}")
except Exception as e:
logger.warning(f"Error scanning {manifest_file}: {e}")
return findings
def _parse_kubesec_output(self, output: str, manifest_file: Path, workspace: Path, config: Dict[str, Any]) -> List[ModuleFinding]:
"""Parse Kubesec JSON output into findings"""
findings = []
if not output.strip():
return findings
try:
# Kubesec outputs JSON array
results = json.loads(output)
if not isinstance(results, list):
results = [results]
threshold = config.get("threshold", 0)
for result in results:
score = result.get("score", 0)
object_name = result.get("object", "Unknown")
valid = result.get("valid", True)
message = result.get("message", "")
# Make file path relative to workspace
try:
rel_path = manifest_file.relative_to(workspace)
file_path = str(rel_path)
except ValueError:
file_path = str(manifest_file)
# Process scoring and advise sections
advise = result.get("advise", [])
scoring = result.get("scoring", {})
# Create findings for low scores
if score < threshold or not valid:
severity = "high" if score < 0 else "medium" if score < 5 else "low"
finding = self.create_finding(
title=f"Kubernetes Security Score Low: {object_name}",
description=message or f"Security score {score} below threshold {threshold}",
severity=severity,
category="kubernetes_security",
file_path=file_path,
recommendation=self._get_score_recommendation(score, advise),
metadata={
"score": score,
"threshold": threshold,
"object": object_name,
"valid": valid,
"advise_count": len(advise),
"scoring_details": scoring
}
)
findings.append(finding)
# Create findings for each advisory
for advisory in advise:
selector = advisory.get("selector", "")
reason = advisory.get("reason", "")
href = advisory.get("href", "")
# Determine severity based on advisory type
severity = self._get_advisory_severity(reason, selector)
category = self._get_advisory_category(reason, selector)
finding = self.create_finding(
title=f"Kubernetes Security Advisory: {selector}",
description=reason,
severity=severity,
category=category,
file_path=file_path,
recommendation=self._get_advisory_recommendation(reason, href),
metadata={
"selector": selector,
"href": href,
"object": object_name,
"advisory_type": "kubesec_advise"
}
)
findings.append(finding)
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse Kubesec output: {e}")
except Exception as e:
logger.warning(f"Error processing Kubesec results: {e}")
return findings
def _get_advisory_severity(self, reason: str, selector: str) -> str:
"""Determine severity based on advisory reason and selector"""
reason_lower = reason.lower()
selector_lower = selector.lower()
# High severity issues
if any(term in reason_lower for term in [
"privileged", "root", "hostnetwork", "hostpid", "hostipc",
"allowprivilegeescalation", "runasroot", "security", "capabilities"
]):
return "high"
# Medium severity issues
elif any(term in reason_lower for term in [
"resources", "limits", "requests", "readonly", "securitycontext"
]):
return "medium"
# Low severity issues
elif any(term in reason_lower for term in [
"labels", "annotations", "probe", "liveness", "readiness"
]):
return "low"
else:
return "medium"
def _get_advisory_category(self, reason: str, selector: str) -> str:
"""Determine category based on advisory"""
reason_lower = reason.lower()
if any(term in reason_lower for term in ["privilege", "root", "security", "capabilities"]):
return "privilege_escalation"
elif any(term in reason_lower for term in ["network", "host"]):
return "network_security"
elif any(term in reason_lower for term in ["resources", "limits"]):
return "resource_management"
elif any(term in reason_lower for term in ["probe", "health"]):
return "health_monitoring"
else:
return "kubernetes_best_practices"
def _get_score_recommendation(self, score: int, advise: List[Dict]) -> str:
"""Generate recommendation based on score and advisories"""
if score < 0:
return "Critical security issues detected. Address all security advisories immediately."
elif score < 5:
return "Low security score detected. Review and implement security best practices."
elif len(advise) > 0:
return f"Security score is {score}. Review {len(advise)} advisory recommendations for improvement."
else:
return "Review Kubernetes security configuration and apply security hardening measures."
def _get_advisory_recommendation(self, reason: str, href: str) -> str:
"""Generate recommendation for advisory"""
if href:
return f"{reason} For more details, see: {href}"
reason_lower = reason.lower()
# Specific recommendations based on common patterns
if "privileged" in reason_lower:
return "Remove privileged: true from security context. Run containers with minimal privileges."
elif "root" in reason_lower or "runasroot" in reason_lower:
return "Configure runAsNonRoot: true and set runAsUser to a non-root user ID."
elif "allowprivilegeescalation" in reason_lower:
return "Set allowPrivilegeEscalation: false to prevent privilege escalation."
elif "resources" in reason_lower:
return "Define resource requests and limits to prevent resource exhaustion."
elif "readonly" in reason_lower:
return "Set readOnlyRootFilesystem: true to prevent filesystem modifications."
elif "capabilities" in reason_lower:
return "Drop unnecessary capabilities and add only required ones."
elif "probe" in reason_lower:
return "Add liveness and readiness probes for better health monitoring."
else:
return f"Address the security concern: {reason}"
def _create_summary(self, findings: List[ModuleFinding], total_files: int) -> Dict[str, Any]:
"""Create analysis summary"""
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
category_counts = {}
object_counts = {}
scores = []
for finding in findings:
# Count by severity
severity_counts[finding.severity] += 1
# Count by category
category = finding.category
category_counts[category] = category_counts.get(category, 0) + 1
# Count by object
obj = finding.metadata.get("object", "unknown")
object_counts[obj] = object_counts.get(obj, 0) + 1
# Collect scores
score = finding.metadata.get("score")
if score is not None:
scores.append(score)
return {
"total_findings": len(findings),
"files_scanned": total_files,
"severity_counts": severity_counts,
"category_counts": category_counts,
"object_counts": object_counts,
"average_score": sum(scores) / len(scores) if scores else 0,
"min_score": min(scores) if scores else 0,
"max_score": max(scores) if scores else 0,
"files_with_issues": len(set(f.file_path for f in findings if f.file_path))
}
@@ -0,0 +1,519 @@
"""
Polaris Infrastructure Security Module
This module uses Polaris to validate Kubernetes resources against security
and best practice policies.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import asyncio
import json
from pathlib import Path
from typing import Dict, Any, List
import subprocess
import logging
from ..base import BaseModule, ModuleMetadata, ModuleFinding, ModuleResult
from . import register_module
logger = logging.getLogger(__name__)
@register_module
class PolarisModule(BaseModule):
"""Polaris Kubernetes best practices validation module"""
def get_metadata(self) -> ModuleMetadata:
"""Get module metadata"""
return ModuleMetadata(
name="polaris",
version="8.5.0",
description="Kubernetes best practices validation and policy enforcement using Polaris",
author="FuzzForge Team",
category="infrastructure",
tags=["kubernetes", "k8s", "policy", "best-practices", "validation"],
input_schema={
"type": "object",
"properties": {
"audit_path": {
"type": "string",
"description": "Path to audit (defaults to workspace)"
},
"config_file": {
"type": "string",
"description": "Path to Polaris config file"
},
"only_show_failed_tests": {
"type": "boolean",
"default": True,
"description": "Show only failed validation tests"
},
"severity_threshold": {
"type": "string",
"enum": ["error", "warning", "info"],
"default": "info",
"description": "Minimum severity level to report"
},
"format": {
"type": "string",
"enum": ["json", "yaml", "pretty"],
"default": "json",
"description": "Output format"
},
"kubernetes_patterns": {
"type": "array",
"items": {"type": "string"},
"default": ["**/*.yaml", "**/*.yml", "**/k8s/*.yaml", "**/kubernetes/*.yaml"],
"description": "Patterns to find Kubernetes manifest files"
},
"exclude_patterns": {
"type": "array",
"items": {"type": "string"},
"description": "File patterns to exclude"
},
"disable_checks": {
"type": "array",
"items": {"type": "string"},
"description": "List of check names to disable"
},
"enable_checks": {
"type": "array",
"items": {"type": "string"},
"description": "List of check names to enable (if using custom config)"
}
}
},
output_schema={
"type": "object",
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"check_name": {"type": "string"},
"severity": {"type": "string"},
"category": {"type": "string"},
"file_path": {"type": "string"},
"resource_name": {"type": "string"}
}
}
}
}
}
)
def validate_config(self, config: Dict[str, Any]) -> bool:
"""Validate configuration"""
severity_threshold = config.get("severity_threshold", "warning")
valid_severities = ["error", "warning", "info"]
if severity_threshold not in valid_severities:
raise ValueError(f"Invalid severity threshold: {severity_threshold}. Valid: {valid_severities}")
format_type = config.get("format", "json")
valid_formats = ["json", "yaml", "pretty"]
if format_type not in valid_formats:
raise ValueError(f"Invalid format: {format_type}. Valid: {valid_formats}")
return True
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
"""Execute Polaris Kubernetes validation"""
self.start_timer()
try:
# Validate inputs
self.validate_config(config)
self.validate_workspace(workspace)
logger.info(f"Running Polaris Kubernetes validation on {workspace}")
# Find all Kubernetes manifests
k8s_files = self._find_kubernetes_files(workspace, config)
if not k8s_files:
logger.info("No Kubernetes manifest files found")
return self.create_result(
findings=[],
status="success",
summary={"total_findings": 0, "files_scanned": 0}
)
logger.info(f"Found {len(k8s_files)} Kubernetes manifest file(s) to validate")
# Run Polaris audit
findings = await self._run_polaris_audit(workspace, config, k8s_files)
# Create summary
summary = self._create_summary(findings, len(k8s_files))
logger.info(f"Polaris found {len(findings)} policy violations across {len(k8s_files)} manifests")
return self.create_result(
findings=findings,
status="success",
summary=summary
)
except Exception as e:
logger.error(f"Polaris module failed: {e}")
return self.create_result(
findings=[],
status="failed",
error=str(e)
)
def _find_kubernetes_files(self, workspace: Path, config: Dict[str, Any]) -> List[Path]:
"""Find Kubernetes manifest files in workspace"""
patterns = config.get("kubernetes_patterns", [
"**/*.yaml", "**/*.yml", "**/k8s/*.yaml", "**/kubernetes/*.yaml"
])
exclude_patterns = config.get("exclude_patterns", [])
k8s_files = []
for pattern in patterns:
files = workspace.glob(pattern)
for file in files:
# Check if file contains Kubernetes resources
if self._is_kubernetes_manifest(file):
# Check if file should be excluded
should_exclude = False
for exclude_pattern in exclude_patterns:
if file.match(exclude_pattern):
should_exclude = True
break
if not should_exclude:
k8s_files.append(file)
return list(set(k8s_files)) # Remove duplicates
def _is_kubernetes_manifest(self, file: Path) -> bool:
"""Check if a file is a Kubernetes manifest"""
try:
content = file.read_text(encoding='utf-8')
# Simple heuristic: check for common Kubernetes fields
k8s_indicators = [
"apiVersion:", "kind:", "metadata:", "spec:",
"Deployment", "Service", "Pod", "ConfigMap",
"Secret", "Ingress", "PersistentVolume"
]
return any(indicator in content for indicator in k8s_indicators)
except Exception:
return False
async def _run_polaris_audit(self, workspace: Path, config: Dict[str, Any], k8s_files: List[Path]) -> List[ModuleFinding]:
"""Run Polaris audit on workspace"""
findings = []
try:
# Build polaris command
cmd = ["polaris", "audit"]
# Add audit path
audit_path = config.get("audit_path", str(workspace))
cmd.extend(["--audit-path", audit_path])
# Add config file if specified
config_file = config.get("config_file")
if config_file:
cmd.extend(["--config", config_file])
# Add format
format_type = config.get("format", "json")
cmd.extend(["--format", format_type])
# Add only failed tests flag
if config.get("only_show_failed_tests", True):
cmd.append("--only-show-failed-tests")
# Add severity threshold
severity_threshold = config.get("severity_threshold", "warning")
cmd.extend(["--severity", severity_threshold])
# Add disable checks
disable_checks = config.get("disable_checks", [])
for check in disable_checks:
cmd.extend(["--disable-check", check])
logger.debug(f"Running command: {' '.join(cmd)}")
# Run polaris
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=workspace
)
stdout, stderr = await process.communicate()
# Parse results
if process.returncode == 0 or format_type == "json":
findings = self._parse_polaris_output(stdout.decode(), workspace, config)
else:
error_msg = stderr.decode()
logger.warning(f"Polaris audit failed: {error_msg}")
except Exception as e:
logger.warning(f"Error running Polaris audit: {e}")
return findings
def _parse_polaris_output(self, output: str, workspace: Path, config: Dict[str, Any]) -> List[ModuleFinding]:
"""Parse Polaris JSON output into findings"""
findings = []
if not output.strip():
return findings
try:
data = json.loads(output)
# Get severity threshold for filtering
severity_threshold = config.get("severity_threshold", "warning")
severity_levels = {"error": 3, "warning": 2, "info": 1}
min_severity_level = severity_levels.get(severity_threshold, 2)
# Process audit results
audit_results = data.get("AuditResults", [])
for result in audit_results:
namespace = result.get("Namespace", "default")
results_by_kind = result.get("Results", {})
for kind, kind_results in results_by_kind.items():
for resource_name, resource_data in kind_results.items():
# Get container results
container_results = resource_data.get("ContainerResults", {})
pod_result = resource_data.get("PodResult", {})
# Process container results
for container_name, container_data in container_results.items():
self._process_container_results(
findings, container_data, kind, resource_name,
container_name, namespace, workspace, min_severity_level
)
# Process pod-level results
if pod_result:
self._process_pod_results(
findings, pod_result, kind, resource_name,
namespace, workspace, min_severity_level
)
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse Polaris output: {e}")
except Exception as e:
logger.warning(f"Error processing Polaris results: {e}")
return findings
def _process_container_results(self, findings: List[ModuleFinding], container_data: Dict,
kind: str, resource_name: str, container_name: str,
namespace: str, workspace: Path, min_severity_level: int):
"""Process container-level validation results"""
results = container_data.get("Results", {})
for check_name, check_result in results.items():
severity = check_result.get("Severity", "warning")
success = check_result.get("Success", True)
message = check_result.get("Message", "")
category_name = check_result.get("Category", "")
# Skip if check passed or severity too low
if success:
continue
severity_levels = {"error": 3, "warning": 2, "info": 1}
if severity_levels.get(severity, 1) < min_severity_level:
continue
# Map severity to our standard levels
finding_severity = self._map_severity(severity)
category = self._get_category(check_name, category_name)
finding = self.create_finding(
title=f"Polaris Policy Violation: {check_name}",
description=message or f"Container {container_name} in {kind} {resource_name} failed check {check_name}",
severity=finding_severity,
category=category,
file_path=None, # Polaris doesn't provide file paths in audit mode
recommendation=self._get_recommendation(check_name, message),
metadata={
"check_name": check_name,
"polaris_severity": severity,
"polaris_category": category_name,
"resource_kind": kind,
"resource_name": resource_name,
"container_name": container_name,
"namespace": namespace,
"context": "container"
}
)
findings.append(finding)
def _process_pod_results(self, findings: List[ModuleFinding], pod_result: Dict,
kind: str, resource_name: str, namespace: str,
workspace: Path, min_severity_level: int):
"""Process pod-level validation results"""
results = pod_result.get("Results", {})
for check_name, check_result in results.items():
severity = check_result.get("Severity", "warning")
success = check_result.get("Success", True)
message = check_result.get("Message", "")
category_name = check_result.get("Category", "")
# Skip if check passed or severity too low
if success:
continue
severity_levels = {"error": 3, "warning": 2, "info": 1}
if severity_levels.get(severity, 1) < min_severity_level:
continue
# Map severity to our standard levels
finding_severity = self._map_severity(severity)
category = self._get_category(check_name, category_name)
finding = self.create_finding(
title=f"Polaris Policy Violation: {check_name}",
description=message or f"{kind} {resource_name} failed check {check_name}",
severity=finding_severity,
category=category,
file_path=None, # Polaris doesn't provide file paths in audit mode
recommendation=self._get_recommendation(check_name, message),
metadata={
"check_name": check_name,
"polaris_severity": severity,
"polaris_category": category_name,
"resource_kind": kind,
"resource_name": resource_name,
"namespace": namespace,
"context": "pod"
}
)
findings.append(finding)
def _map_severity(self, polaris_severity: str) -> str:
"""Map Polaris severity to our standard severity levels"""
severity_map = {
"error": "high",
"warning": "medium",
"info": "low"
}
return severity_map.get(polaris_severity.lower(), "medium")
def _get_category(self, check_name: str, category_name: str) -> str:
"""Determine finding category based on check name and category"""
check_lower = check_name.lower()
category_lower = category_name.lower()
# Use Polaris category if available
if "security" in category_lower:
return "security_configuration"
elif "efficiency" in category_lower:
return "resource_efficiency"
elif "reliability" in category_lower:
return "reliability"
# Fallback to check name analysis
if any(term in check_lower for term in ["security", "privilege", "root", "capabilities"]):
return "security_configuration"
elif any(term in check_lower for term in ["resources", "limits", "requests"]):
return "resource_management"
elif any(term in check_lower for term in ["probe", "health", "liveness", "readiness"]):
return "health_monitoring"
elif any(term in check_lower for term in ["image", "tag", "pull"]):
return "image_management"
elif any(term in check_lower for term in ["network", "host"]):
return "network_security"
else:
return "kubernetes_best_practices"
def _get_recommendation(self, check_name: str, message: str) -> str:
"""Generate recommendation based on check name and message"""
check_lower = check_name.lower()
# Security-related recommendations
if "privileged" in check_lower:
return "Remove privileged: true from container security context to reduce security risks."
elif "runasroot" in check_lower:
return "Configure runAsNonRoot: true and specify a non-root user ID."
elif "allowprivilegeescalation" in check_lower:
return "Set allowPrivilegeEscalation: false to prevent privilege escalation attacks."
elif "capabilities" in check_lower:
return "Remove unnecessary capabilities and add only required ones using drop/add lists."
elif "readonly" in check_lower:
return "Set readOnlyRootFilesystem: true to prevent filesystem modifications."
# Resource management recommendations
elif "memory" in check_lower and "requests" in check_lower:
return "Set memory requests to ensure proper resource allocation and scheduling."
elif "memory" in check_lower and "limits" in check_lower:
return "Set memory limits to prevent containers from using excessive memory."
elif "cpu" in check_lower and "requests" in check_lower:
return "Set CPU requests for proper resource allocation and quality of service."
elif "cpu" in check_lower and "limits" in check_lower:
return "Set CPU limits to prevent CPU starvation of other containers."
# Health monitoring recommendations
elif "liveness" in check_lower:
return "Add liveness probes to detect and recover from container failures."
elif "readiness" in check_lower:
return "Add readiness probes to ensure containers are ready before receiving traffic."
# Image management recommendations
elif "tag" in check_lower:
return "Use specific image tags instead of 'latest' for reproducible deployments."
elif "pullpolicy" in check_lower:
return "Set imagePullPolicy appropriately based on your deployment requirements."
# Generic recommendation
elif message:
return f"Address the policy violation: {message}"
else:
return f"Review and fix the configuration issue identified by check: {check_name}"
def _create_summary(self, findings: List[ModuleFinding], total_files: int) -> Dict[str, Any]:
"""Create analysis summary"""
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
category_counts = {}
check_counts = {}
resource_counts = {}
for finding in findings:
# Count by severity
severity_counts[finding.severity] += 1
# Count by category
category = finding.category
category_counts[category] = category_counts.get(category, 0) + 1
# Count by check
check_name = finding.metadata.get("check_name", "unknown")
check_counts[check_name] = check_counts.get(check_name, 0) + 1
# Count by resource
resource_kind = finding.metadata.get("resource_kind", "unknown")
resource_counts[resource_kind] = resource_counts.get(resource_kind, 0) + 1
return {
"total_findings": len(findings),
"files_scanned": total_files,
"severity_counts": severity_counts,
"category_counts": category_counts,
"top_checks": dict(sorted(check_counts.items(), key=lambda x: x[1], reverse=True)[:10]),
"resource_type_counts": resource_counts,
"unique_resources": len(set(f"{f.metadata.get('resource_kind')}:{f.metadata.get('resource_name')}" for f in findings)),
"namespaces": len(set(f.metadata.get("namespace", "default") for f in findings))
}
@@ -0,0 +1,43 @@
"""
Penetration Testing Modules
This package contains modules for penetration testing and vulnerability assessment.
Available modules:
- Nuclei: Fast and customizable vulnerability scanner
- Nmap: Network discovery and security auditing
- Masscan: High-speed Internet-wide port scanner
- SQLMap: Automatic SQL injection detection and exploitation
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
from typing import List, Type
from ..base import BaseModule
# Module registry for automatic discovery
PENETRATION_TESTING_MODULES: List[Type[BaseModule]] = []
def register_module(module_class: Type[BaseModule]):
"""Register a penetration testing module"""
PENETRATION_TESTING_MODULES.append(module_class)
return module_class
def get_available_modules() -> List[Type[BaseModule]]:
"""Get all available penetration testing modules"""
return PENETRATION_TESTING_MODULES.copy()
# Import modules to trigger registration
from .nuclei import NucleiModule
from .nmap import NmapModule
from .masscan import MasscanModule
from .sqlmap import SQLMapModule
@@ -0,0 +1,607 @@
"""
Masscan Penetration Testing Module
This module uses Masscan for high-speed Internet-wide port scanning.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import asyncio
import json
from pathlib import Path
from typing import Dict, Any, List
import subprocess
import logging
from ..base import BaseModule, ModuleMetadata, ModuleFinding, ModuleResult
from . import register_module
logger = logging.getLogger(__name__)
@register_module
class MasscanModule(BaseModule):
"""Masscan high-speed port scanner module"""
def get_metadata(self) -> ModuleMetadata:
"""Get module metadata"""
return ModuleMetadata(
name="masscan",
version="1.3.2",
description="High-speed Internet-wide port scanner for large-scale network discovery",
author="FuzzForge Team",
category="penetration_testing",
tags=["port-scan", "network", "discovery", "high-speed", "mass-scan"],
input_schema={
"type": "object",
"properties": {
"targets": {
"type": "array",
"items": {"type": "string"},
"description": "List of targets (IP addresses, CIDR ranges, domains)"
},
"target_file": {
"type": "string",
"description": "File containing targets to scan"
},
"ports": {
"type": "string",
"default": "1-1000",
"description": "Port range or specific ports to scan"
},
"top_ports": {
"type": "integer",
"description": "Scan top N most common ports"
},
"rate": {
"type": "integer",
"default": 1000,
"description": "Packet transmission rate (packets/second)"
},
"max_rate": {
"type": "integer",
"description": "Maximum packet rate limit"
},
"connection_timeout": {
"type": "integer",
"default": 10,
"description": "Connection timeout in seconds"
},
"wait_time": {
"type": "integer",
"default": 10,
"description": "Time to wait for responses (seconds)"
},
"retries": {
"type": "integer",
"default": 0,
"description": "Number of retries for failed connections"
},
"randomize_hosts": {
"type": "boolean",
"default": True,
"description": "Randomize host order"
},
"source_ip": {
"type": "string",
"description": "Source IP address to use"
},
"source_port": {
"type": "string",
"description": "Source port range to use"
},
"interface": {
"type": "string",
"description": "Network interface to use"
},
"router_mac": {
"type": "string",
"description": "Router MAC address"
},
"exclude_targets": {
"type": "array",
"items": {"type": "string"},
"description": "Targets to exclude from scanning"
},
"exclude_file": {
"type": "string",
"description": "File containing targets to exclude"
},
"ping": {
"type": "boolean",
"default": False,
"description": "Include ping scan"
},
"banners": {
"type": "boolean",
"default": False,
"description": "Grab banners from services"
},
"http_user_agent": {
"type": "string",
"description": "HTTP User-Agent string for banner grabbing"
}
}
},
output_schema={
"type": "object",
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"host": {"type": "string"},
"port": {"type": "integer"},
"protocol": {"type": "string"},
"state": {"type": "string"},
"banner": {"type": "string"}
}
}
}
}
}
)
def validate_config(self, config: Dict[str, Any]) -> bool:
"""Validate configuration"""
targets = config.get("targets", [])
target_file = config.get("target_file")
if not targets and not target_file:
raise ValueError("Either 'targets' or 'target_file' must be specified")
rate = config.get("rate", 1000)
if rate <= 0 or rate > 10000000: # Masscan limit
raise ValueError("Rate must be between 1 and 10,000,000 packets/second")
return True
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
"""Execute Masscan port scanning"""
self.start_timer()
try:
# Validate inputs
self.validate_config(config)
self.validate_workspace(workspace)
logger.info("Running Masscan high-speed port scan")
# Prepare target specification
target_args = self._prepare_targets(config, workspace)
if not target_args:
logger.info("No targets specified for scanning")
return self.create_result(
findings=[],
status="success",
summary={"total_findings": 0, "targets_scanned": 0}
)
# Run Masscan scan
findings = await self._run_masscan_scan(target_args, config, workspace)
# Create summary
target_count = len(config.get("targets", [])) if config.get("targets") else 1
summary = self._create_summary(findings, target_count)
logger.info(f"Masscan found {len(findings)} open ports")
return self.create_result(
findings=findings,
status="success",
summary=summary
)
except Exception as e:
logger.error(f"Masscan module failed: {e}")
return self.create_result(
findings=[],
status="failed",
error=str(e)
)
def _prepare_targets(self, config: Dict[str, Any], workspace: Path) -> List[str]:
"""Prepare target arguments for masscan"""
target_args = []
# Add targets from list
targets = config.get("targets", [])
for target in targets:
target_args.extend(["-t", target])
# Add targets from file
target_file = config.get("target_file")
if target_file:
target_path = workspace / target_file
if target_path.exists():
target_args.extend(["-iL", str(target_path)])
else:
raise FileNotFoundError(f"Target file not found: {target_file}")
return target_args
async def _run_masscan_scan(self, target_args: List[str], config: Dict[str, Any], workspace: Path) -> List[ModuleFinding]:
"""Run Masscan scan"""
findings = []
try:
# Build masscan command
cmd = ["masscan"]
# Add target arguments
cmd.extend(target_args)
# Add port specification
if config.get("top_ports"):
# Masscan doesn't have built-in top ports, use common ports
top_ports = self._get_top_ports(config["top_ports"])
cmd.extend(["-p", top_ports])
else:
ports = config.get("ports", "1-1000")
cmd.extend(["-p", ports])
# Add rate limiting
rate = config.get("rate", 1000)
cmd.extend(["--rate", str(rate)])
# Add max rate if specified
max_rate = config.get("max_rate")
if max_rate:
cmd.extend(["--max-rate", str(max_rate)])
# Add connection timeout
connection_timeout = config.get("connection_timeout", 10)
cmd.extend(["--connection-timeout", str(connection_timeout)])
# Add wait time
wait_time = config.get("wait_time", 10)
cmd.extend(["--wait", str(wait_time)])
# Add retries
retries = config.get("retries", 0)
if retries > 0:
cmd.extend(["--retries", str(retries)])
# Add randomization
if config.get("randomize_hosts", True):
cmd.append("--randomize-hosts")
# Add source IP
source_ip = config.get("source_ip")
if source_ip:
cmd.extend(["--source-ip", source_ip])
# Add source port
source_port = config.get("source_port")
if source_port:
cmd.extend(["--source-port", source_port])
# Add interface
interface = config.get("interface")
if interface:
cmd.extend(["-e", interface])
# Add router MAC
router_mac = config.get("router_mac")
if router_mac:
cmd.extend(["--router-mac", router_mac])
# Add exclude targets
exclude_targets = config.get("exclude_targets", [])
for exclude in exclude_targets:
cmd.extend(["--exclude", exclude])
# Add exclude file
exclude_file = config.get("exclude_file")
if exclude_file:
exclude_path = workspace / exclude_file
if exclude_path.exists():
cmd.extend(["--excludefile", str(exclude_path)])
# Add ping scan
if config.get("ping", False):
cmd.append("--ping")
# Add banner grabbing
if config.get("banners", False):
cmd.append("--banners")
# Add HTTP User-Agent
user_agent = config.get("http_user_agent")
if user_agent:
cmd.extend(["--http-user-agent", user_agent])
# Set output format to JSON
output_file = workspace / "masscan_results.json"
cmd.extend(["-oJ", str(output_file)])
logger.debug(f"Running command: {' '.join(cmd)}")
# Run masscan
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=workspace
)
stdout, stderr = await process.communicate()
# Parse results from JSON file
if output_file.exists():
findings = self._parse_masscan_json(output_file, workspace)
else:
# Try to parse stdout if no file was created
if stdout:
findings = self._parse_masscan_output(stdout.decode(), workspace)
else:
error_msg = stderr.decode()
logger.error(f"Masscan scan failed: {error_msg}")
except Exception as e:
logger.warning(f"Error running Masscan scan: {e}")
return findings
def _get_top_ports(self, count: int) -> str:
"""Get top N common ports for masscan"""
# Common ports based on Nmap's top ports list
top_ports = [
80, 23, 443, 21, 22, 25, 53, 110, 111, 995, 993, 143, 993, 995, 587, 465,
109, 88, 53, 135, 139, 445, 993, 995, 143, 25, 110, 465, 587, 993, 995,
80, 8080, 443, 8443, 8000, 8888, 8880, 2222, 9999, 3389, 5900, 5901,
1433, 3306, 5432, 1521, 50000, 1494, 554, 37, 79, 82, 5060, 50030
]
# Take first N unique ports
selected_ports = list(dict.fromkeys(top_ports))[:count]
return ",".join(map(str, selected_ports))
def _parse_masscan_json(self, json_file: Path, workspace: Path) -> List[ModuleFinding]:
"""Parse Masscan JSON output into findings"""
findings = []
try:
with open(json_file, 'r') as f:
content = f.read().strip()
# Masscan outputs JSONL format (one JSON object per line)
for line in content.split('\n'):
if not line.strip():
continue
try:
result = json.loads(line)
finding = self._process_masscan_result(result)
if finding:
findings.append(finding)
except json.JSONDecodeError:
continue
except Exception as e:
logger.warning(f"Error parsing Masscan JSON: {e}")
return findings
def _parse_masscan_output(self, output: str, workspace: Path) -> List[ModuleFinding]:
"""Parse Masscan text output into findings"""
findings = []
try:
for line in output.split('\n'):
if not line.strip() or line.startswith('#'):
continue
# Parse format: "open tcp 80 1.2.3.4"
parts = line.split()
if len(parts) >= 4 and parts[0] == "open":
protocol = parts[1]
port = int(parts[2])
ip = parts[3]
result = {
"ip": ip,
"ports": [{"port": port, "proto": protocol, "status": "open"}]
}
finding = self._process_masscan_result(result)
if finding:
findings.append(finding)
except Exception as e:
logger.warning(f"Error parsing Masscan output: {e}")
return findings
def _process_masscan_result(self, result: Dict) -> ModuleFinding:
"""Process a single Masscan result into a finding"""
try:
ip_address = result.get("ip", "")
ports_data = result.get("ports", [])
if not ip_address or not ports_data:
return None
# Process first port (Masscan typically reports one port per result)
port_data = ports_data[0]
port_number = port_data.get("port", 0)
protocol = port_data.get("proto", "tcp")
status = port_data.get("status", "open")
service = port_data.get("service", {})
banner = service.get("banner", "") if service else ""
# Only report open ports
if status != "open":
return None
# Determine severity based on port
severity = self._get_port_severity(port_number)
# Get category
category = self._get_port_category(port_number)
# Create description
description = f"Open port {port_number}/{protocol} on {ip_address}"
if banner:
description += f" (Banner: {banner[:100]})"
# Create finding
finding = self.create_finding(
title=f"Open Port: {port_number}/{protocol}",
description=description,
severity=severity,
category=category,
file_path=None, # Network scan, no file
recommendation=self._get_port_recommendation(port_number, banner),
metadata={
"host": ip_address,
"port": port_number,
"protocol": protocol,
"status": status,
"banner": banner,
"service_info": service
}
)
return finding
except Exception as e:
logger.warning(f"Error processing Masscan result: {e}")
return None
def _get_port_severity(self, port: int) -> str:
"""Determine severity based on port number"""
# High risk ports (commonly exploited or sensitive services)
high_risk_ports = [21, 23, 135, 139, 445, 1433, 1521, 3389, 5900, 6379, 27017]
# Medium risk ports (network services that could be risky if misconfigured)
medium_risk_ports = [22, 25, 53, 110, 143, 993, 995, 3306, 5432]
# Web ports are generally lower risk but still noteworthy
web_ports = [80, 443, 8080, 8443, 8000, 8888]
if port in high_risk_ports:
return "high"
elif port in medium_risk_ports:
return "medium"
elif port in web_ports:
return "low"
elif port < 1024: # Well-known ports
return "medium"
else:
return "low"
def _get_port_category(self, port: int) -> str:
"""Determine category based on port number"""
if port in [80, 443, 8080, 8443, 8000, 8888]:
return "web_services"
elif port == 22:
return "remote_access"
elif port in [20, 21]:
return "file_transfer"
elif port in [25, 110, 143, 587, 993, 995]:
return "email_services"
elif port in [1433, 3306, 5432, 1521, 27017, 6379]:
return "database_services"
elif port == 3389:
return "remote_desktop"
elif port == 53:
return "dns_services"
elif port in [135, 139, 445]:
return "windows_services"
elif port in [23, 5900]:
return "insecure_protocols"
else:
return "network_services"
def _get_port_recommendation(self, port: int, banner: str) -> str:
"""Generate recommendation based on port and banner"""
# Port-specific recommendations
recommendations = {
21: "FTP service detected. Consider using SFTP instead for secure file transfer.",
22: "SSH service detected. Ensure strong authentication and key-based access.",
23: "Telnet service detected. Replace with SSH for secure remote access.",
25: "SMTP service detected. Ensure proper authentication and encryption.",
53: "DNS service detected. Verify it's not an open resolver.",
80: "HTTP service detected. Consider upgrading to HTTPS.",
110: "POP3 service detected. Consider using secure alternatives like IMAPS.",
135: "Windows RPC service exposed. Restrict access if not required.",
139: "NetBIOS service detected. Ensure proper access controls.",
143: "IMAP service detected. Consider using encrypted IMAPS.",
445: "SMB service detected. Ensure latest patches and access controls.",
443: "HTTPS service detected. Verify SSL/TLS configuration.",
993: "IMAPS service detected. Verify certificate configuration.",
995: "POP3S service detected. Verify certificate configuration.",
1433: "SQL Server detected. Ensure strong authentication and network restrictions.",
1521: "Oracle DB detected. Ensure proper security configuration.",
3306: "MySQL service detected. Secure with strong passwords and access controls.",
3389: "RDP service detected. Use strong passwords and consider VPN access.",
5432: "PostgreSQL detected. Ensure proper authentication and access controls.",
5900: "VNC service detected. Use strong passwords and encryption.",
6379: "Redis service detected. Configure authentication and access controls.",
8080: "HTTP proxy/web service detected. Verify if exposure is intended.",
8443: "HTTPS service on non-standard port. Verify certificate configuration."
}
recommendation = recommendations.get(port, f"Port {port} is open. Verify if this service is required and properly secured.")
# Add banner-specific advice
if banner:
banner_lower = banner.lower()
if "default" in banner_lower or "admin" in banner_lower:
recommendation += " Default credentials may be in use - change immediately."
elif any(version in banner_lower for version in ["1.0", "2.0", "old", "legacy"]):
recommendation += " Service version appears outdated - consider upgrading."
return recommendation
def _create_summary(self, findings: List[ModuleFinding], targets_count: int) -> Dict[str, Any]:
"""Create analysis summary"""
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
category_counts = {}
port_counts = {}
host_counts = {}
protocol_counts = {"tcp": 0, "udp": 0}
for finding in findings:
# Count by severity
severity_counts[finding.severity] += 1
# Count by category
category = finding.category
category_counts[category] = category_counts.get(category, 0) + 1
# Count by port
port = finding.metadata.get("port")
if port:
port_counts[port] = port_counts.get(port, 0) + 1
# Count by host
host = finding.metadata.get("host", "unknown")
host_counts[host] = host_counts.get(host, 0) + 1
# Count by protocol
protocol = finding.metadata.get("protocol", "tcp")
if protocol in protocol_counts:
protocol_counts[protocol] += 1
return {
"total_findings": len(findings),
"targets_scanned": targets_count,
"severity_counts": severity_counts,
"category_counts": category_counts,
"protocol_counts": protocol_counts,
"unique_hosts": len(host_counts),
"top_ports": dict(sorted(port_counts.items(), key=lambda x: x[1], reverse=True)[:10]),
"host_counts": dict(sorted(host_counts.items(), key=lambda x: x[1], reverse=True)[:10])
}
@@ -0,0 +1,710 @@
"""
Nmap Penetration Testing Module
This module uses Nmap for network discovery, port scanning, and security auditing.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import asyncio
import json
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Dict, Any, List
import subprocess
import logging
from ..base import BaseModule, ModuleMetadata, ModuleFinding, ModuleResult
from . import register_module
logger = logging.getLogger(__name__)
@register_module
class NmapModule(BaseModule):
"""Nmap network discovery and security auditing module"""
def get_metadata(self) -> ModuleMetadata:
"""Get module metadata"""
return ModuleMetadata(
name="nmap",
version="7.94",
description="Network discovery and security auditing using Nmap",
author="FuzzForge Team",
category="penetration_testing",
tags=["network", "port-scan", "discovery", "security-audit", "service-detection"],
input_schema={
"type": "object",
"properties": {
"targets": {
"type": "array",
"items": {"type": "string"},
"description": "List of targets (IP addresses, domains, CIDR ranges)"
},
"target_file": {
"type": "string",
"description": "File containing targets to scan"
},
"scan_type": {
"type": "string",
"enum": ["syn", "tcp", "udp", "ack", "window", "maimon"],
"default": "syn",
"description": "Type of scan to perform"
},
"ports": {
"type": "string",
"default": "1-1000",
"description": "Port range or specific ports to scan"
},
"top_ports": {
"type": "integer",
"description": "Scan top N most common ports"
},
"service_detection": {
"type": "boolean",
"default": True,
"description": "Enable service version detection"
},
"os_detection": {
"type": "boolean",
"default": False,
"description": "Enable OS detection (requires root)"
},
"script_scan": {
"type": "boolean",
"default": True,
"description": "Enable default NSE scripts"
},
"scripts": {
"type": "array",
"items": {"type": "string"},
"description": "Specific NSE scripts to run"
},
"script_categories": {
"type": "array",
"items": {"type": "string"},
"description": "NSE script categories to run (safe, vuln, etc.)"
},
"timing_template": {
"type": "string",
"enum": ["paranoid", "sneaky", "polite", "normal", "aggressive", "insane"],
"default": "normal",
"description": "Timing template (0-5)"
},
"max_retries": {
"type": "integer",
"default": 1,
"description": "Maximum number of retries"
},
"host_timeout": {
"type": "integer",
"default": 300,
"description": "Host timeout in seconds"
},
"min_rate": {
"type": "integer",
"description": "Minimum packet rate (packets/second)"
},
"max_rate": {
"type": "integer",
"description": "Maximum packet rate (packets/second)"
},
"stealth": {
"type": "boolean",
"default": False,
"description": "Enable stealth scanning options"
},
"skip_discovery": {
"type": "boolean",
"default": False,
"description": "Skip host discovery (treat all as online)"
}
}
},
output_schema={
"type": "object",
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"host": {"type": "string"},
"port": {"type": "integer"},
"service": {"type": "string"},
"state": {"type": "string"},
"version": {"type": "string"}
}
}
}
}
}
)
def validate_config(self, config: Dict[str, Any]) -> bool:
"""Validate configuration"""
targets = config.get("targets", [])
target_file = config.get("target_file")
if not targets and not target_file:
raise ValueError("Either 'targets' or 'target_file' must be specified")
scan_type = config.get("scan_type", "syn")
valid_scan_types = ["syn", "tcp", "udp", "ack", "window", "maimon"]
if scan_type not in valid_scan_types:
raise ValueError(f"Invalid scan type: {scan_type}. Valid: {valid_scan_types}")
timing = config.get("timing_template", "normal")
valid_timings = ["paranoid", "sneaky", "polite", "normal", "aggressive", "insane"]
if timing not in valid_timings:
raise ValueError(f"Invalid timing template: {timing}. Valid: {valid_timings}")
return True
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
"""Execute Nmap network scanning"""
self.start_timer()
try:
# Validate inputs
self.validate_config(config)
self.validate_workspace(workspace)
logger.info("Running Nmap network scan")
# Prepare target file
target_file = await self._prepare_targets(config, workspace)
if not target_file:
logger.info("No targets specified for scanning")
return self.create_result(
findings=[],
status="success",
summary={"total_findings": 0, "hosts_scanned": 0}
)
# Run Nmap scan
findings = await self._run_nmap_scan(target_file, config, workspace)
# Create summary
target_count = len(config.get("targets", [])) if config.get("targets") else 1
summary = self._create_summary(findings, target_count)
logger.info(f"Nmap found {len(findings)} results")
return self.create_result(
findings=findings,
status="success",
summary=summary
)
except Exception as e:
logger.error(f"Nmap module failed: {e}")
return self.create_result(
findings=[],
status="failed",
error=str(e)
)
async def _prepare_targets(self, config: Dict[str, Any], workspace: Path) -> Path:
"""Prepare target file for scanning"""
targets = config.get("targets", [])
target_file = config.get("target_file")
if target_file:
# Use existing target file
target_path = workspace / target_file
if target_path.exists():
return target_path
else:
raise FileNotFoundError(f"Target file not found: {target_file}")
if targets:
# Create temporary target file
target_path = workspace / "nmap_targets.txt"
with open(target_path, 'w') as f:
for target in targets:
f.write(f"{target}\n")
return target_path
return None
async def _run_nmap_scan(self, target_file: Path, config: Dict[str, Any], workspace: Path) -> List[ModuleFinding]:
"""Run Nmap scan"""
findings = []
try:
# Build nmap command
cmd = ["nmap"]
# Add scan type
scan_type = config.get("scan_type", "syn")
scan_type_map = {
"syn": "-sS",
"tcp": "-sT",
"udp": "-sU",
"ack": "-sA",
"window": "-sW",
"maimon": "-sM"
}
cmd.append(scan_type_map[scan_type])
# Add port specification
if config.get("top_ports"):
cmd.extend(["--top-ports", str(config["top_ports"])])
else:
ports = config.get("ports", "1-1000")
cmd.extend(["-p", ports])
# Add service detection
if config.get("service_detection", True):
cmd.append("-sV")
# Add OS detection
if config.get("os_detection", False):
cmd.append("-O")
# Add script scanning
if config.get("script_scan", True):
cmd.append("-sC")
# Add specific scripts
scripts = config.get("scripts", [])
if scripts:
cmd.extend(["--script", ",".join(scripts)])
# Add script categories
script_categories = config.get("script_categories", [])
if script_categories:
cmd.extend(["--script", ",".join(script_categories)])
# Add timing template
timing = config.get("timing_template", "normal")
timing_map = {
"paranoid": "-T0",
"sneaky": "-T1",
"polite": "-T2",
"normal": "-T3",
"aggressive": "-T4",
"insane": "-T5"
}
cmd.append(timing_map[timing])
# Add retry options
max_retries = config.get("max_retries", 1)
cmd.extend(["--max-retries", str(max_retries)])
# Add timeout
host_timeout = config.get("host_timeout", 300)
cmd.extend(["--host-timeout", f"{host_timeout}s"])
# Add rate limiting
if config.get("min_rate"):
cmd.extend(["--min-rate", str(config["min_rate"])])
if config.get("max_rate"):
cmd.extend(["--max-rate", str(config["max_rate"])])
# Add stealth options
if config.get("stealth", False):
cmd.extend(["-f", "--randomize-hosts"])
# Skip host discovery if requested
if config.get("skip_discovery", False):
cmd.append("-Pn")
# Add output format
output_file = workspace / "nmap_results.xml"
cmd.extend(["-oX", str(output_file)])
# Add targets from file
cmd.extend(["-iL", str(target_file)])
# Add verbose and reason flags
cmd.extend(["-v", "--reason"])
logger.debug(f"Running command: {' '.join(cmd)}")
# Run nmap
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=workspace
)
stdout, stderr = await process.communicate()
# Parse results from XML file
if output_file.exists():
findings = self._parse_nmap_xml(output_file, workspace)
else:
error_msg = stderr.decode()
logger.error(f"Nmap scan failed: {error_msg}")
except Exception as e:
logger.warning(f"Error running Nmap scan: {e}")
return findings
def _parse_nmap_xml(self, xml_file: Path, workspace: Path) -> List[ModuleFinding]:
"""Parse Nmap XML output into findings"""
findings = []
try:
tree = ET.parse(xml_file)
root = tree.getroot()
# Process each host
for host_elem in root.findall(".//host"):
# Get host information
host_status = host_elem.find("status")
if host_status is None or host_status.get("state") != "up":
continue
# Get IP address
address_elem = host_elem.find("address[@addrtype='ipv4']")
if address_elem is None:
address_elem = host_elem.find("address[@addrtype='ipv6']")
if address_elem is None:
continue
ip_address = address_elem.get("addr")
# Get hostname if available
hostname = ""
hostnames_elem = host_elem.find("hostnames")
if hostnames_elem is not None:
hostname_elem = hostnames_elem.find("hostname")
if hostname_elem is not None:
hostname = hostname_elem.get("name", "")
# Get OS information
os_info = self._extract_os_info(host_elem)
# Process ports
ports_elem = host_elem.find("ports")
if ports_elem is not None:
for port_elem in ports_elem.findall("port"):
finding = self._process_port(port_elem, ip_address, hostname, os_info)
if finding:
findings.append(finding)
# Process host scripts
host_scripts = host_elem.find("hostscript")
if host_scripts is not None:
for script_elem in host_scripts.findall("script"):
finding = self._process_host_script(script_elem, ip_address, hostname)
if finding:
findings.append(finding)
except ET.ParseError as e:
logger.warning(f"Failed to parse Nmap XML: {e}")
except Exception as e:
logger.warning(f"Error processing Nmap results: {e}")
return findings
def _extract_os_info(self, host_elem) -> Dict[str, Any]:
"""Extract OS information from host element"""
os_info = {}
os_elem = host_elem.find("os")
if os_elem is not None:
osmatch_elem = os_elem.find("osmatch")
if osmatch_elem is not None:
os_info["name"] = osmatch_elem.get("name", "")
os_info["accuracy"] = osmatch_elem.get("accuracy", "0")
return os_info
def _process_port(self, port_elem, ip_address: str, hostname: str, os_info: Dict) -> ModuleFinding:
"""Process a port element into a finding"""
try:
port_id = port_elem.get("portid")
protocol = port_elem.get("protocol")
# Get state
state_elem = port_elem.find("state")
if state_elem is None:
return None
state = state_elem.get("state")
reason = state_elem.get("reason", "")
# Only report open ports
if state != "open":
return None
# Get service information
service_elem = port_elem.find("service")
service_name = ""
service_version = ""
service_product = ""
service_extra = ""
if service_elem is not None:
service_name = service_elem.get("name", "")
service_version = service_elem.get("version", "")
service_product = service_elem.get("product", "")
service_extra = service_elem.get("extrainfo", "")
# Determine severity based on service
severity = self._get_port_severity(int(port_id), service_name)
# Get category
category = self._get_port_category(int(port_id), service_name)
# Create description
desc_parts = [f"Open port {port_id}/{protocol}"]
if service_name:
desc_parts.append(f"running {service_name}")
if service_product:
desc_parts.append(f"({service_product}")
if service_version:
desc_parts.append(f"version {service_version}")
desc_parts.append(")")
description = " ".join(desc_parts)
# Process port scripts
script_results = []
script_elems = port_elem.findall("script")
for script_elem in script_elems:
script_id = script_elem.get("id", "")
script_output = script_elem.get("output", "")
if script_output:
script_results.append({"id": script_id, "output": script_output})
# Create finding
finding = self.create_finding(
title=f"Open Port: {port_id}/{protocol}",
description=description,
severity=severity,
category=category,
file_path=None, # Network scan, no file
recommendation=self._get_port_recommendation(int(port_id), service_name, script_results),
metadata={
"host": ip_address,
"hostname": hostname,
"port": int(port_id),
"protocol": protocol,
"state": state,
"reason": reason,
"service_name": service_name,
"service_version": service_version,
"service_product": service_product,
"service_extra": service_extra,
"os_info": os_info,
"script_results": script_results
}
)
return finding
except Exception as e:
logger.warning(f"Error processing port: {e}")
return None
def _process_host_script(self, script_elem, ip_address: str, hostname: str) -> ModuleFinding:
"""Process a host script result into a finding"""
try:
script_id = script_elem.get("id", "")
script_output = script_elem.get("output", "")
if not script_output or not script_id:
return None
# Determine if this is a security issue
severity = self._get_script_severity(script_id, script_output)
if severity == "info":
# Skip informational scripts
return None
category = self._get_script_category(script_id)
finding = self.create_finding(
title=f"Host Script Result: {script_id}",
description=script_output.strip(),
severity=severity,
category=category,
file_path=None,
recommendation=self._get_script_recommendation(script_id, script_output),
metadata={
"host": ip_address,
"hostname": hostname,
"script_id": script_id,
"script_output": script_output.strip()
}
)
return finding
except Exception as e:
logger.warning(f"Error processing host script: {e}")
return None
def _get_port_severity(self, port: int, service: str) -> str:
"""Determine severity based on port and service"""
# High risk ports
high_risk_ports = [21, 23, 135, 139, 445, 1433, 1521, 3389, 5432, 5900, 6379]
# Medium risk ports
medium_risk_ports = [22, 25, 53, 110, 143, 993, 995]
# Web ports are generally lower risk
web_ports = [80, 443, 8080, 8443, 8000, 8888]
if port in high_risk_ports:
return "high"
elif port in medium_risk_ports:
return "medium"
elif port in web_ports:
return "low"
elif port < 1024: # Well-known ports
return "medium"
else:
return "low"
def _get_port_category(self, port: int, service: str) -> str:
"""Determine category based on port and service"""
service_lower = service.lower()
if service_lower in ["http", "https"] or port in [80, 443, 8080, 8443]:
return "web_services"
elif service_lower in ["ssh"] or port == 22:
return "remote_access"
elif service_lower in ["ftp", "ftps"] or port in [20, 21]:
return "file_transfer"
elif service_lower in ["smtp", "pop3", "imap"] or port in [25, 110, 143, 587, 993, 995]:
return "email_services"
elif service_lower in ["mysql", "postgresql", "mssql", "oracle"] or port in [1433, 3306, 5432, 1521]:
return "database_services"
elif service_lower in ["rdp"] or port == 3389:
return "remote_desktop"
elif service_lower in ["dns"] or port == 53:
return "dns_services"
elif port in [135, 139, 445]:
return "windows_services"
else:
return "network_services"
def _get_script_severity(self, script_id: str, output: str) -> str:
"""Determine severity for script results"""
script_lower = script_id.lower()
output_lower = output.lower()
# High severity indicators
if any(term in script_lower for term in ["vuln", "exploit", "backdoor"]):
return "high"
if any(term in output_lower for term in ["vulnerable", "exploit", "critical"]):
return "high"
# Medium severity indicators
if any(term in script_lower for term in ["auth", "brute", "enum"]):
return "medium"
if any(term in output_lower for term in ["anonymous", "default", "weak"]):
return "medium"
# Everything else is informational
return "info"
def _get_script_category(self, script_id: str) -> str:
"""Determine category for script results"""
script_lower = script_id.lower()
if "vuln" in script_lower:
return "vulnerability_detection"
elif "auth" in script_lower or "brute" in script_lower:
return "authentication_testing"
elif "enum" in script_lower:
return "information_gathering"
elif "ssl" in script_lower or "tls" in script_lower:
return "ssl_tls_testing"
else:
return "service_detection"
def _get_port_recommendation(self, port: int, service: str, scripts: List[Dict]) -> str:
"""Generate recommendation for open port"""
# Check for script-based issues
for script in scripts:
script_id = script.get("id", "")
if "vuln" in script_id.lower():
return "Vulnerability detected by NSE scripts. Review and patch the service."
# Port-specific recommendations
if port == 21:
return "FTP service detected. Consider using SFTP instead for secure file transfer."
elif port == 23:
return "Telnet service detected. Use SSH instead for secure remote access."
elif port == 135:
return "Windows RPC service exposed. Restrict access if not required."
elif port in [139, 445]:
return "SMB/NetBIOS services detected. Ensure proper access controls and patch levels."
elif port == 1433:
return "SQL Server detected. Ensure strong authentication and network restrictions."
elif port == 3389:
return "RDP service detected. Use strong passwords and consider VPN access."
elif port in [80, 443]:
return "Web service detected. Ensure regular security updates and proper configuration."
else:
return f"Open port {port} detected. Verify if this service is required and properly secured."
def _get_script_recommendation(self, script_id: str, output: str) -> str:
"""Generate recommendation for script results"""
if "vuln" in script_id.lower():
return "Vulnerability detected. Apply security patches and updates."
elif "auth" in script_id.lower():
return "Authentication issue detected. Review and strengthen authentication mechanisms."
elif "ssl" in script_id.lower():
return "SSL/TLS configuration issue. Update SSL configuration and certificates."
else:
return "Review the script output and address any security concerns identified."
def _create_summary(self, findings: List[ModuleFinding], hosts_count: int) -> Dict[str, Any]:
"""Create analysis summary"""
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
category_counts = {}
port_counts = {}
service_counts = {}
host_counts = {}
for finding in findings:
# Count by severity
severity_counts[finding.severity] += 1
# Count by category
category = finding.category
category_counts[category] = category_counts.get(category, 0) + 1
# Count by port
port = finding.metadata.get("port")
if port:
port_counts[port] = port_counts.get(port, 0) + 1
# Count by service
service = finding.metadata.get("service_name", "unknown")
service_counts[service] = service_counts.get(service, 0) + 1
# Count by host
host = finding.metadata.get("host", "unknown")
host_counts[host] = host_counts.get(host, 0) + 1
return {
"total_findings": len(findings),
"hosts_scanned": hosts_count,
"severity_counts": severity_counts,
"category_counts": category_counts,
"unique_hosts": len(host_counts),
"top_ports": dict(sorted(port_counts.items(), key=lambda x: x[1], reverse=True)[:10]),
"top_services": dict(sorted(service_counts.items(), key=lambda x: x[1], reverse=True)[:10]),
"host_counts": dict(sorted(host_counts.items(), key=lambda x: x[1], reverse=True)[:5])
}
@@ -0,0 +1,501 @@
"""
Nuclei Penetration Testing Module
This module uses Nuclei to perform fast and customizable vulnerability scanning
using community-powered templates.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import asyncio
import json
from pathlib import Path
from typing import Dict, Any, List
import subprocess
import logging
from ..base import BaseModule, ModuleMetadata, ModuleFinding, ModuleResult
from . import register_module
logger = logging.getLogger(__name__)
@register_module
class NucleiModule(BaseModule):
"""Nuclei fast vulnerability scanner module"""
def get_metadata(self) -> ModuleMetadata:
"""Get module metadata"""
return ModuleMetadata(
name="nuclei",
version="3.1.0",
description="Fast and customizable vulnerability scanner using community-powered templates",
author="FuzzForge Team",
category="penetration_testing",
tags=["vulnerability", "scanner", "web", "network", "templates"],
input_schema={
"type": "object",
"properties": {
"targets": {
"type": "array",
"items": {"type": "string"},
"description": "List of targets (URLs, domains, IP addresses)"
},
"target_file": {
"type": "string",
"description": "File containing targets to scan"
},
"templates": {
"type": "array",
"items": {"type": "string"},
"description": "Specific templates to use"
},
"template_directory": {
"type": "string",
"description": "Directory containing custom templates"
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Template tags to include"
},
"exclude_tags": {
"type": "array",
"items": {"type": "string"},
"description": "Template tags to exclude"
},
"severity": {
"type": "array",
"items": {"type": "string", "enum": ["critical", "high", "medium", "low", "info"]},
"default": ["critical", "high", "medium"],
"description": "Severity levels to include"
},
"concurrency": {
"type": "integer",
"default": 25,
"description": "Number of concurrent threads"
},
"rate_limit": {
"type": "integer",
"default": 150,
"description": "Rate limit (requests per second)"
},
"timeout": {
"type": "integer",
"default": 10,
"description": "Timeout for requests (seconds)"
},
"retries": {
"type": "integer",
"default": 1,
"description": "Number of retries for failed requests"
},
"update_templates": {
"type": "boolean",
"default": False,
"description": "Update templates before scanning"
},
"disable_clustering": {
"type": "boolean",
"default": False,
"description": "Disable template clustering"
},
"no_interactsh": {
"type": "boolean",
"default": True,
"description": "Disable interactsh server for OAST testing"
}
}
},
output_schema={
"type": "object",
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"template_id": {"type": "string"},
"name": {"type": "string"},
"severity": {"type": "string"},
"host": {"type": "string"},
"matched_at": {"type": "string"}
}
}
}
}
}
)
def validate_config(self, config: Dict[str, Any]) -> bool:
"""Validate configuration"""
targets = config.get("targets", [])
target_file = config.get("target_file")
if not targets and not target_file:
raise ValueError("Either 'targets' or 'target_file' must be specified")
severity_levels = config.get("severity", [])
valid_severities = ["critical", "high", "medium", "low", "info"]
for severity in severity_levels:
if severity not in valid_severities:
raise ValueError(f"Invalid severity: {severity}. Valid: {valid_severities}")
return True
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
"""Execute Nuclei vulnerability scanning"""
self.start_timer()
try:
# Validate inputs
self.validate_config(config)
self.validate_workspace(workspace)
logger.info("Running Nuclei vulnerability scan")
# Update templates if requested
if config.get("update_templates", False):
await self._update_templates(workspace)
# Prepare target file
target_file = await self._prepare_targets(config, workspace)
if not target_file:
logger.info("No targets specified for scanning")
return self.create_result(
findings=[],
status="success",
summary={"total_findings": 0, "targets_scanned": 0}
)
# Run Nuclei scan
findings = await self._run_nuclei_scan(target_file, config, workspace)
# Create summary
summary = self._create_summary(findings, len(config.get("targets", [])))
logger.info(f"Nuclei found {len(findings)} vulnerabilities")
return self.create_result(
findings=findings,
status="success",
summary=summary
)
except Exception as e:
logger.error(f"Nuclei module failed: {e}")
return self.create_result(
findings=[],
status="failed",
error=str(e)
)
async def _update_templates(self, workspace: Path):
"""Update Nuclei templates"""
try:
logger.info("Updating Nuclei templates...")
cmd = ["nuclei", "-update-templates"]
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=workspace
)
stdout, stderr = await process.communicate()
if process.returncode == 0:
logger.info("Templates updated successfully")
else:
logger.warning(f"Template update failed: {stderr.decode()}")
except Exception as e:
logger.warning(f"Error updating templates: {e}")
async def _prepare_targets(self, config: Dict[str, Any], workspace: Path) -> Path:
"""Prepare target file for scanning"""
targets = config.get("targets", [])
target_file = config.get("target_file")
if target_file:
# Use existing target file
target_path = workspace / target_file
if target_path.exists():
return target_path
else:
raise FileNotFoundError(f"Target file not found: {target_file}")
if targets:
# Create temporary target file
target_path = workspace / "nuclei_targets.txt"
with open(target_path, 'w') as f:
for target in targets:
f.write(f"{target}\n")
return target_path
return None
async def _run_nuclei_scan(self, target_file: Path, config: Dict[str, Any], workspace: Path) -> List[ModuleFinding]:
"""Run Nuclei scan"""
findings = []
try:
# Build nuclei command
cmd = ["nuclei", "-l", str(target_file)]
# Add output format
cmd.extend(["-json"])
# Add templates
templates = config.get("templates", [])
if templates:
cmd.extend(["-t", ",".join(templates)])
# Add template directory
template_dir = config.get("template_directory")
if template_dir:
cmd.extend(["-t", template_dir])
# Add tags
tags = config.get("tags", [])
if tags:
cmd.extend(["-tags", ",".join(tags)])
# Add exclude tags
exclude_tags = config.get("exclude_tags", [])
if exclude_tags:
cmd.extend(["-exclude-tags", ",".join(exclude_tags)])
# Add severity
severity_levels = config.get("severity", ["critical", "high", "medium"])
cmd.extend(["-severity", ",".join(severity_levels)])
# Add concurrency
concurrency = config.get("concurrency", 25)
cmd.extend(["-c", str(concurrency)])
# Add rate limit
rate_limit = config.get("rate_limit", 150)
cmd.extend(["-rl", str(rate_limit)])
# Add timeout
timeout = config.get("timeout", 10)
cmd.extend(["-timeout", str(timeout)])
# Add retries
retries = config.get("retries", 1)
cmd.extend(["-retries", str(retries)])
# Add other flags
if config.get("disable_clustering", False):
cmd.append("-no-color")
if config.get("no_interactsh", True):
cmd.append("-no-interactsh")
# Add silent flag for JSON output
cmd.append("-silent")
logger.debug(f"Running command: {' '.join(cmd)}")
# Run nuclei
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=workspace
)
stdout, stderr = await process.communicate()
# Parse results
if process.returncode == 0 or stdout:
findings = self._parse_nuclei_output(stdout.decode(), workspace)
else:
error_msg = stderr.decode()
logger.error(f"Nuclei scan failed: {error_msg}")
except Exception as e:
logger.warning(f"Error running Nuclei scan: {e}")
return findings
def _parse_nuclei_output(self, output: str, workspace: Path) -> List[ModuleFinding]:
"""Parse Nuclei JSON output into findings"""
findings = []
if not output.strip():
return findings
try:
# Parse each line as JSON (JSONL format)
for line in output.strip().split('\n'):
if not line.strip():
continue
result = json.loads(line)
# Extract information
template_id = result.get("template-id", "")
template_name = result.get("info", {}).get("name", "")
severity = result.get("info", {}).get("severity", "medium")
host = result.get("host", "")
matched_at = result.get("matched-at", "")
description = result.get("info", {}).get("description", "")
reference = result.get("info", {}).get("reference", [])
classification = result.get("info", {}).get("classification", {})
extracted_results = result.get("extracted-results", [])
# Map severity to our standard levels
finding_severity = self._map_severity(severity)
# Get category based on template
category = self._get_category(template_id, template_name, classification)
# Create finding
finding = self.create_finding(
title=f"Nuclei Detection: {template_name}",
description=description or f"Vulnerability detected using template {template_id}",
severity=finding_severity,
category=category,
file_path=None, # Nuclei scans network targets
recommendation=self._get_recommendation(template_id, template_name, reference),
metadata={
"template_id": template_id,
"template_name": template_name,
"nuclei_severity": severity,
"host": host,
"matched_at": matched_at,
"classification": classification,
"reference": reference,
"extracted_results": extracted_results
}
)
findings.append(finding)
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse Nuclei output: {e}")
except Exception as e:
logger.warning(f"Error processing Nuclei results: {e}")
return findings
def _map_severity(self, nuclei_severity: str) -> str:
"""Map Nuclei severity to our standard severity levels"""
severity_map = {
"critical": "critical",
"high": "high",
"medium": "medium",
"low": "low",
"info": "info"
}
return severity_map.get(nuclei_severity.lower(), "medium")
def _get_category(self, template_id: str, template_name: str, classification: Dict) -> str:
"""Determine finding category based on template and classification"""
template_lower = f"{template_id} {template_name}".lower()
# Use classification if available
cwe_id = classification.get("cwe-id")
if cwe_id:
# Map common CWE IDs to categories
if cwe_id in ["CWE-79", "CWE-80"]:
return "cross_site_scripting"
elif cwe_id in ["CWE-89"]:
return "sql_injection"
elif cwe_id in ["CWE-22", "CWE-23"]:
return "path_traversal"
elif cwe_id in ["CWE-352"]:
return "csrf"
elif cwe_id in ["CWE-601"]:
return "redirect"
# Analyze template content
if any(term in template_lower for term in ["xss", "cross-site"]):
return "cross_site_scripting"
elif any(term in template_lower for term in ["sql", "injection"]):
return "sql_injection"
elif any(term in template_lower for term in ["lfi", "rfi", "file", "path", "traversal"]):
return "file_inclusion"
elif any(term in template_lower for term in ["rce", "command", "execution"]):
return "remote_code_execution"
elif any(term in template_lower for term in ["auth", "login", "bypass"]):
return "authentication_bypass"
elif any(term in template_lower for term in ["disclosure", "exposure", "leak"]):
return "information_disclosure"
elif any(term in template_lower for term in ["config", "misconfiguration"]):
return "misconfiguration"
elif any(term in template_lower for term in ["cve-"]):
return "known_vulnerability"
else:
return "web_vulnerability"
def _get_recommendation(self, template_id: str, template_name: str, references: List) -> str:
"""Generate recommendation based on template"""
# Use references if available
if references:
ref_text = ", ".join(references[:3]) # Limit to first 3 references
return f"Review the vulnerability and apply appropriate fixes. References: {ref_text}"
# Generate based on template type
template_lower = f"{template_id} {template_name}".lower()
if "xss" in template_lower:
return "Implement proper input validation and output encoding to prevent XSS attacks."
elif "sql" in template_lower:
return "Use parameterized queries and input validation to prevent SQL injection."
elif "lfi" in template_lower or "rfi" in template_lower:
return "Validate and sanitize file paths. Avoid dynamic file includes with user input."
elif "rce" in template_lower:
return "Sanitize user input and avoid executing system commands with user-controlled data."
elif "auth" in template_lower:
return "Review authentication mechanisms and implement proper access controls."
elif "exposure" in template_lower or "disclosure" in template_lower:
return "Restrict access to sensitive information and implement proper authorization."
elif "cve-" in template_lower:
return "Update the affected software to the latest version to patch known vulnerabilities."
else:
return f"Review and remediate the security issue identified by template {template_id}."
def _create_summary(self, findings: List[ModuleFinding], targets_count: int) -> Dict[str, Any]:
"""Create analysis summary"""
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
category_counts = {}
template_counts = {}
host_counts = {}
for finding in findings:
# Count by severity
severity_counts[finding.severity] += 1
# Count by category
category = finding.category
category_counts[category] = category_counts.get(category, 0) + 1
# Count by template
template_id = finding.metadata.get("template_id", "unknown")
template_counts[template_id] = template_counts.get(template_id, 0) + 1
# Count by host
host = finding.metadata.get("host", "unknown")
host_counts[host] = host_counts.get(host, 0) + 1
return {
"total_findings": len(findings),
"targets_scanned": targets_count,
"severity_counts": severity_counts,
"category_counts": category_counts,
"top_templates": dict(sorted(template_counts.items(), key=lambda x: x[1], reverse=True)[:10]),
"affected_hosts": len(host_counts),
"host_counts": dict(sorted(host_counts.items(), key=lambda x: x[1], reverse=True)[:10])
}
@@ -0,0 +1,671 @@
"""
SQLMap Penetration Testing Module
This module uses SQLMap for automatic SQL injection detection and exploitation.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import asyncio
import json
from pathlib import Path
from typing import Dict, Any, List
import subprocess
import logging
from ..base import BaseModule, ModuleMetadata, ModuleFinding, ModuleResult
from . import register_module
logger = logging.getLogger(__name__)
@register_module
class SQLMapModule(BaseModule):
"""SQLMap automatic SQL injection detection and exploitation module"""
def get_metadata(self) -> ModuleMetadata:
"""Get module metadata"""
return ModuleMetadata(
name="sqlmap",
version="1.7.11",
description="Automatic SQL injection detection and exploitation tool",
author="FuzzForge Team",
category="penetration_testing",
tags=["sql-injection", "web", "database", "vulnerability", "exploitation"],
input_schema={
"type": "object",
"properties": {
"target_url": {
"type": "string",
"description": "Target URL to test for SQL injection"
},
"target_file": {
"type": "string",
"description": "File containing URLs to test"
},
"request_file": {
"type": "string",
"description": "Load HTTP request from file (Burp log, etc.)"
},
"data": {
"type": "string",
"description": "Data string to be sent through POST"
},
"cookie": {
"type": "string",
"description": "HTTP Cookie header value"
},
"user_agent": {
"type": "string",
"description": "HTTP User-Agent header value"
},
"referer": {
"type": "string",
"description": "HTTP Referer header value"
},
"headers": {
"type": "object",
"description": "Additional HTTP headers"
},
"method": {
"type": "string",
"enum": ["GET", "POST", "PUT", "DELETE", "PATCH"],
"default": "GET",
"description": "HTTP method to use"
},
"testable_parameters": {
"type": "array",
"items": {"type": "string"},
"description": "Comma-separated list of testable parameter(s)"
},
"skip_parameters": {
"type": "array",
"items": {"type": "string"},
"description": "Parameters to skip during testing"
},
"dbms": {
"type": "string",
"enum": ["mysql", "postgresql", "oracle", "mssql", "sqlite", "access", "firebird", "sybase", "db2", "hsqldb", "h2"],
"description": "Force back-end DBMS to provided value"
},
"level": {
"type": "integer",
"enum": [1, 2, 3, 4, 5],
"default": 1,
"description": "Level of tests to perform (1-5)"
},
"risk": {
"type": "integer",
"enum": [1, 2, 3],
"default": 1,
"description": "Risk of tests to perform (1-3)"
},
"technique": {
"type": "array",
"items": {"type": "string", "enum": ["B", "E", "U", "S", "T", "Q"]},
"description": "SQL injection techniques to use (B=Boolean, E=Error, U=Union, S=Stacked, T=Time, Q=Inline)"
},
"time_sec": {
"type": "integer",
"default": 5,
"description": "Seconds to delay DBMS response for time-based blind SQL injection"
},
"union_cols": {
"type": "string",
"description": "Range of columns to test for UNION query SQL injection"
},
"threads": {
"type": "integer",
"default": 1,
"description": "Maximum number of concurrent HTTP requests"
},
"timeout": {
"type": "integer",
"default": 30,
"description": "Seconds to wait before timeout connection"
},
"retries": {
"type": "integer",
"default": 3,
"description": "Retries when connection timeouts"
},
"randomize": {
"type": "boolean",
"default": True,
"description": "Randomly change value of given parameter(s)"
},
"safe_url": {
"type": "string",
"description": "URL to visit frequently during testing"
},
"safe_freq": {
"type": "integer",
"description": "Test requests between visits to safe URL"
},
"crawl": {
"type": "integer",
"description": "Crawl website starting from target URL (depth)"
},
"forms": {
"type": "boolean",
"default": False,
"description": "Parse and test forms on target URL"
},
"batch": {
"type": "boolean",
"default": True,
"description": "Never ask for user input, use default behavior"
},
"cleanup": {
"type": "boolean",
"default": True,
"description": "Clean up files used by SQLMap"
},
"check_waf": {
"type": "boolean",
"default": False,
"description": "Check for existence of WAF/IPS protection"
},
"tamper": {
"type": "array",
"items": {"type": "string"},
"description": "Use tamper scripts to modify requests"
}
}
},
output_schema={
"type": "object",
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"url": {"type": "string"},
"parameter": {"type": "string"},
"technique": {"type": "string"},
"dbms": {"type": "string"},
"payload": {"type": "string"}
}
}
}
}
}
)
def validate_config(self, config: Dict[str, Any]) -> bool:
"""Validate configuration"""
target_url = config.get("target_url")
target_file = config.get("target_file")
request_file = config.get("request_file")
if not any([target_url, target_file, request_file]):
raise ValueError("Either 'target_url', 'target_file', or 'request_file' must be specified")
level = config.get("level", 1)
if level not in [1, 2, 3, 4, 5]:
raise ValueError("Level must be between 1 and 5")
risk = config.get("risk", 1)
if risk not in [1, 2, 3]:
raise ValueError("Risk must be between 1 and 3")
return True
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
"""Execute SQLMap SQL injection testing"""
self.start_timer()
try:
# Validate inputs
self.validate_config(config)
self.validate_workspace(workspace)
logger.info("Running SQLMap SQL injection scan")
# Run SQLMap scan
findings = await self._run_sqlmap_scan(config, workspace)
# Create summary
summary = self._create_summary(findings)
logger.info(f"SQLMap found {len(findings)} SQL injection vulnerabilities")
return self.create_result(
findings=findings,
status="success",
summary=summary
)
except Exception as e:
logger.error(f"SQLMap module failed: {e}")
return self.create_result(
findings=[],
status="failed",
error=str(e)
)
async def _run_sqlmap_scan(self, config: Dict[str, Any], workspace: Path) -> List[ModuleFinding]:
"""Run SQLMap scan"""
findings = []
try:
# Build sqlmap command
cmd = ["sqlmap"]
# Add target specification
target_url = config.get("target_url")
if target_url:
cmd.extend(["-u", target_url])
target_file = config.get("target_file")
if target_file:
target_path = workspace / target_file
if target_path.exists():
cmd.extend(["-m", str(target_path)])
else:
raise FileNotFoundError(f"Target file not found: {target_file}")
request_file = config.get("request_file")
if request_file:
request_path = workspace / request_file
if request_path.exists():
cmd.extend(["-r", str(request_path)])
else:
raise FileNotFoundError(f"Request file not found: {request_file}")
# Add HTTP options
data = config.get("data")
if data:
cmd.extend(["--data", data])
cookie = config.get("cookie")
if cookie:
cmd.extend(["--cookie", cookie])
user_agent = config.get("user_agent")
if user_agent:
cmd.extend(["--user-agent", user_agent])
referer = config.get("referer")
if referer:
cmd.extend(["--referer", referer])
headers = config.get("headers", {})
for key, value in headers.items():
cmd.extend(["--header", f"{key}: {value}"])
method = config.get("method")
if method and method != "GET":
cmd.extend(["--method", method])
# Add parameter options
testable_params = config.get("testable_parameters", [])
if testable_params:
cmd.extend(["-p", ",".join(testable_params)])
skip_params = config.get("skip_parameters", [])
if skip_params:
cmd.extend(["--skip", ",".join(skip_params)])
# Add injection options
dbms = config.get("dbms")
if dbms:
cmd.extend(["--dbms", dbms])
level = config.get("level", 1)
cmd.extend(["--level", str(level)])
risk = config.get("risk", 1)
cmd.extend(["--risk", str(risk)])
techniques = config.get("technique", [])
if techniques:
cmd.extend(["--technique", "".join(techniques)])
time_sec = config.get("time_sec", 5)
cmd.extend(["--time-sec", str(time_sec)])
union_cols = config.get("union_cols")
if union_cols:
cmd.extend(["--union-cols", union_cols])
# Add performance options
threads = config.get("threads", 1)
cmd.extend(["--threads", str(threads)])
timeout = config.get("timeout", 30)
cmd.extend(["--timeout", str(timeout)])
retries = config.get("retries", 3)
cmd.extend(["--retries", str(retries)])
# Add request options
if config.get("randomize", True):
cmd.append("--randomize")
safe_url = config.get("safe_url")
if safe_url:
cmd.extend(["--safe-url", safe_url])
safe_freq = config.get("safe_freq")
if safe_freq:
cmd.extend(["--safe-freq", str(safe_freq)])
# Add crawling options
crawl_depth = config.get("crawl")
if crawl_depth:
cmd.extend(["--crawl", str(crawl_depth)])
if config.get("forms", False):
cmd.append("--forms")
# Add behavioral options
if config.get("batch", True):
cmd.append("--batch")
if config.get("cleanup", True):
cmd.append("--cleanup")
if config.get("check_waf", False):
cmd.append("--check-waf")
# Add tamper scripts
tamper_scripts = config.get("tamper", [])
if tamper_scripts:
cmd.extend(["--tamper", ",".join(tamper_scripts)])
# Set output directory
output_dir = workspace / "sqlmap_output"
output_dir.mkdir(exist_ok=True)
cmd.extend(["--output-dir", str(output_dir)])
# Add format for easier parsing
cmd.append("--flush-session") # Start fresh
cmd.append("--fresh-queries") # Ignore previous results
logger.debug(f"Running command: {' '.join(cmd)}")
# Run sqlmap
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=workspace
)
stdout, stderr = await process.communicate()
# Parse results from output directory
findings = self._parse_sqlmap_output(output_dir, stdout.decode(), workspace)
# Log results
if findings:
logger.info(f"SQLMap detected {len(findings)} SQL injection vulnerabilities")
else:
logger.info("No SQL injection vulnerabilities found")
# Check for errors
stderr_text = stderr.decode()
if stderr_text:
logger.warning(f"SQLMap warnings/errors: {stderr_text}")
except Exception as e:
logger.warning(f"Error running SQLMap scan: {e}")
return findings
def _parse_sqlmap_output(self, output_dir: Path, stdout: str, workspace: Path) -> List[ModuleFinding]:
"""Parse SQLMap output into findings"""
findings = []
try:
# Look for session files in output directory
session_files = list(output_dir.glob("**/*.sqlite"))
log_files = list(output_dir.glob("**/*.log"))
# Parse stdout for injection information
findings.extend(self._parse_stdout_output(stdout))
# Parse log files for additional details
for log_file in log_files:
findings.extend(self._parse_log_file(log_file))
# If we have session files, we can extract more detailed information
# For now, we'll rely on stdout parsing
except Exception as e:
logger.warning(f"Error parsing SQLMap output: {e}")
return findings
def _parse_stdout_output(self, stdout: str) -> List[ModuleFinding]:
"""Parse SQLMap stdout for SQL injection findings"""
findings = []
try:
lines = stdout.split('\n')
current_url = None
current_parameter = None
current_technique = None
current_dbms = None
injection_found = False
for line in lines:
line = line.strip()
# Extract URL being tested
if "testing URL" in line or "testing connection to the target URL" in line:
# Extract URL from line
if "'" in line:
url_start = line.find("'") + 1
url_end = line.find("'", url_start)
if url_end > url_start:
current_url = line[url_start:url_end]
# Extract parameter being tested
elif "testing parameter" in line or "testing" in line and "parameter" in line:
if "'" in line:
param_parts = line.split("'")
if len(param_parts) >= 2:
current_parameter = param_parts[1]
# Detect SQL injection found
elif any(indicator in line.lower() for indicator in [
"parameter appears to be vulnerable",
"injectable",
"parameter is vulnerable"
]):
injection_found = True
# Extract technique information
elif "Type:" in line:
current_technique = line.replace("Type:", "").strip()
# Extract database information
elif "back-end DBMS:" in line.lower():
current_dbms = line.split(":")[-1].strip()
# Extract payload information
elif "Payload:" in line:
payload = line.replace("Payload:", "").strip()
# Create finding if we have injection
if injection_found and current_url and current_parameter:
finding = self._create_sqlmap_finding(
current_url, current_parameter, current_technique,
current_dbms, payload
)
if finding:
findings.append(finding)
# Reset state
injection_found = False
current_technique = None
except Exception as e:
logger.warning(f"Error parsing SQLMap stdout: {e}")
return findings
def _parse_log_file(self, log_file: Path) -> List[ModuleFinding]:
"""Parse SQLMap log file for additional findings"""
findings = []
try:
with open(log_file, 'r') as f:
content = f.read()
# Look for injection indicators in log
if "injectable" in content.lower() or "vulnerable" in content.lower():
# Could parse more detailed information from log
# For now, we'll rely on stdout parsing
pass
except Exception as e:
logger.warning(f"Error parsing log file {log_file}: {e}")
return findings
def _create_sqlmap_finding(self, url: str, parameter: str, technique: str, dbms: str, payload: str) -> ModuleFinding:
"""Create a ModuleFinding for SQL injection"""
try:
# Map technique to readable description
technique_map = {
"boolean-based blind": "Boolean-based blind SQL injection",
"time-based blind": "Time-based blind SQL injection",
"error-based": "Error-based SQL injection",
"UNION query": "UNION-based SQL injection",
"stacked queries": "Stacked queries SQL injection",
"inline query": "Inline query SQL injection"
}
technique_desc = technique_map.get(technique, technique or "SQL injection")
# Create description
description = f"SQL injection vulnerability detected in parameter '{parameter}' using {technique_desc}"
if dbms:
description += f" against {dbms} database"
# Determine severity based on technique
severity = self._get_injection_severity(technique, dbms)
# Create finding
finding = self.create_finding(
title=f"SQL Injection: {parameter}",
description=description,
severity=severity,
category="sql_injection",
file_path=None, # Web application testing
recommendation=self._get_sqlinjection_recommendation(technique, dbms),
metadata={
"url": url,
"parameter": parameter,
"technique": technique,
"dbms": dbms,
"payload": payload[:500] if payload else "", # Limit payload length
"injection_type": technique_desc
}
)
return finding
except Exception as e:
logger.warning(f"Error creating SQLMap finding: {e}")
return None
def _get_injection_severity(self, technique: str, dbms: str) -> str:
"""Determine severity based on injection technique and database"""
if not technique:
return "high" # Any SQL injection is serious
technique_lower = technique.lower()
# Critical severity for techniques that allow easy data extraction
if any(term in technique_lower for term in ["union", "error-based"]):
return "critical"
# High severity for techniques that allow some data extraction
elif any(term in technique_lower for term in ["boolean-based", "time-based"]):
return "high"
# Stacked queries are very dangerous as they allow multiple statements
elif "stacked" in technique_lower:
return "critical"
else:
return "high"
def _get_sqlinjection_recommendation(self, technique: str, dbms: str) -> str:
"""Generate recommendation for SQL injection"""
base_recommendation = "Implement parameterized queries/prepared statements and input validation to prevent SQL injection attacks."
if technique:
technique_lower = technique.lower()
if "union" in technique_lower:
base_recommendation += " The UNION-based injection allows direct data extraction - immediate remediation required."
elif "error-based" in technique_lower:
base_recommendation += " Error-based injection reveals database structure - disable error messages in production."
elif "time-based" in technique_lower:
base_recommendation += " Time-based injection allows blind data extraction - implement query timeout limits."
elif "stacked" in technique_lower:
base_recommendation += " Stacked queries injection allows multiple SQL statements - extremely dangerous, fix immediately."
if dbms:
dbms_lower = dbms.lower()
if "mysql" in dbms_lower:
base_recommendation += " For MySQL: disable LOAD_FILE and INTO OUTFILE if not needed."
elif "postgresql" in dbms_lower:
base_recommendation += " For PostgreSQL: review user privileges and disable unnecessary functions."
elif "mssql" in dbms_lower:
base_recommendation += " For SQL Server: disable xp_cmdshell and review extended stored procedures."
return base_recommendation
def _create_summary(self, findings: List[ModuleFinding]) -> Dict[str, Any]:
"""Create analysis summary"""
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
technique_counts = {}
dbms_counts = {}
parameter_counts = {}
url_counts = {}
for finding in findings:
# Count by severity
severity_counts[finding.severity] += 1
# Count by technique
technique = finding.metadata.get("technique", "unknown")
technique_counts[technique] = technique_counts.get(technique, 0) + 1
# Count by DBMS
dbms = finding.metadata.get("dbms", "unknown")
if dbms != "unknown":
dbms_counts[dbms] = dbms_counts.get(dbms, 0) + 1
# Count by parameter
parameter = finding.metadata.get("parameter", "unknown")
parameter_counts[parameter] = parameter_counts.get(parameter, 0) + 1
# Count by URL
url = finding.metadata.get("url", "unknown")
url_counts[url] = url_counts.get(url, 0) + 1
return {
"total_findings": len(findings),
"severity_counts": severity_counts,
"technique_counts": technique_counts,
"dbms_counts": dbms_counts,
"vulnerable_parameters": list(parameter_counts.keys()),
"vulnerable_urls": len(url_counts),
"most_common_techniques": dict(sorted(technique_counts.items(), key=lambda x: x[1], reverse=True)[:5]),
"affected_databases": list(dbms_counts.keys())
}
@@ -0,0 +1,14 @@
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
from .sarif_reporter import SARIFReporter
__all__ = ["SARIFReporter"]
@@ -0,0 +1,401 @@
"""
SARIF Reporter Module - Generates SARIF-formatted security reports
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import logging
from pathlib import Path
from typing import Dict, Any, List
from datetime import datetime
import json
try:
from toolbox.modules.base import BaseModule, ModuleMetadata, ModuleResult, ModuleFinding
except ImportError:
try:
from modules.base import BaseModule, ModuleMetadata, ModuleResult, ModuleFinding
except ImportError:
from src.toolbox.modules.base import BaseModule, ModuleMetadata, ModuleResult, ModuleFinding
logger = logging.getLogger(__name__)
class SARIFReporter(BaseModule):
"""
Generates SARIF (Static Analysis Results Interchange Format) reports.
This module:
- Converts findings to SARIF format
- Aggregates results from multiple modules
- Adds metadata and context
- Provides actionable recommendations
"""
def get_metadata(self) -> ModuleMetadata:
"""Get module metadata"""
return ModuleMetadata(
name="sarif_reporter",
version="1.0.0",
description="Generates SARIF-formatted security reports",
author="FuzzForge Team",
category="reporter",
tags=["reporting", "sarif", "output"],
input_schema={
"findings": {
"type": "array",
"description": "List of findings to report",
"required": True
},
"tool_name": {
"type": "string",
"description": "Name of the tool",
"default": "FuzzForge Security Assessment"
},
"tool_version": {
"type": "string",
"description": "Tool version",
"default": "1.0.0"
},
"include_code_flows": {
"type": "boolean",
"description": "Include code flow information",
"default": False
}
},
output_schema={
"sarif": {
"type": "object",
"description": "SARIF 2.1.0 formatted report"
}
},
requires_workspace=False # Reporter doesn't need direct workspace access
)
def validate_config(self, config: Dict[str, Any]) -> bool:
"""Validate module configuration"""
if "findings" not in config and "modules_results" not in config:
raise ValueError("Either 'findings' or 'modules_results' must be provided")
return True
async def execute(self, config: Dict[str, Any], workspace: Path = None) -> ModuleResult:
"""
Execute the SARIF reporter module.
Args:
config: Module configuration with findings
workspace: Optional workspace path for context
Returns:
ModuleResult with SARIF report
"""
self.start_timer()
self.validate_config(config)
# Get configuration
tool_name = config.get("tool_name", "FuzzForge Security Assessment")
tool_version = config.get("tool_version", "1.0.0")
include_code_flows = config.get("include_code_flows", False)
# Collect findings from either direct findings or module results
all_findings = []
if "findings" in config:
# Direct findings provided
all_findings = config["findings"]
if isinstance(all_findings, list) and all(isinstance(f, dict) for f in all_findings):
# Convert dict findings to ModuleFinding objects
all_findings = [ModuleFinding(**f) if isinstance(f, dict) else f for f in all_findings]
elif "modules_results" in config:
# Aggregate from module results
for module_result in config["modules_results"]:
if isinstance(module_result, dict):
findings = module_result.get("findings", [])
all_findings.extend(findings)
elif hasattr(module_result, "findings"):
all_findings.extend(module_result.findings)
logger.info(f"Generating SARIF report for {len(all_findings)} findings")
try:
# Generate SARIF report
sarif_report = self._generate_sarif(
findings=all_findings,
tool_name=tool_name,
tool_version=tool_version,
include_code_flows=include_code_flows,
workspace_path=str(workspace) if workspace else None
)
# Create summary
summary = self._generate_report_summary(all_findings)
return ModuleResult(
module=self.get_metadata().name,
version=self.get_metadata().version,
status="success",
execution_time=self.get_execution_time(),
findings=[], # Reporter doesn't generate new findings
summary=summary,
metadata={
"tool_name": tool_name,
"tool_version": tool_version,
"report_format": "SARIF 2.1.0",
"total_findings": len(all_findings)
},
error=None,
sarif=sarif_report # Add SARIF as custom field
)
except Exception as e:
logger.error(f"SARIF reporter failed: {e}")
return self.create_result(
findings=[],
status="failed",
error=str(e)
)
def _generate_sarif(
self,
findings: List[ModuleFinding],
tool_name: str,
tool_version: str,
include_code_flows: bool,
workspace_path: str = None
) -> Dict[str, Any]:
"""
Generate SARIF 2.1.0 formatted report.
Args:
findings: List of findings to report
tool_name: Name of the tool
tool_version: Tool version
include_code_flows: Whether to include code flow information
workspace_path: Optional workspace path
Returns:
SARIF formatted dictionary
"""
# Create rules from unique finding types
rules = self._create_rules(findings)
# Create results from findings
results = self._create_results(findings, include_code_flows)
# Build SARIF structure
sarif = {
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
"version": "2.1.0",
"runs": [
{
"tool": {
"driver": {
"name": tool_name,
"version": tool_version,
"informationUri": "https://fuzzforge.io",
"rules": rules
}
},
"results": results,
"invocations": [
{
"executionSuccessful": True,
"endTimeUtc": datetime.utcnow().isoformat() + "Z"
}
]
}
]
}
# Add workspace information if available
if workspace_path:
sarif["runs"][0]["originalUriBaseIds"] = {
"WORKSPACE": {
"uri": f"file://{workspace_path}/",
"description": "The workspace root directory"
}
}
return sarif
def _create_rules(self, findings: List[ModuleFinding]) -> List[Dict[str, Any]]:
"""
Create SARIF rules from findings.
Args:
findings: List of findings
Returns:
List of SARIF rule objects
"""
rules_dict = {}
for finding in findings:
rule_id = f"{finding.category}_{finding.severity}"
if rule_id not in rules_dict:
rules_dict[rule_id] = {
"id": rule_id,
"name": finding.category.replace("_", " ").title(),
"shortDescription": {
"text": f"{finding.category} vulnerability"
},
"fullDescription": {
"text": f"Detection rule for {finding.category} vulnerabilities with {finding.severity} severity"
},
"defaultConfiguration": {
"level": self._severity_to_sarif_level(finding.severity)
},
"properties": {
"category": finding.category,
"severity": finding.severity,
"tags": ["security", finding.category, finding.severity]
}
}
return list(rules_dict.values())
def _create_results(
self, findings: List[ModuleFinding], include_code_flows: bool
) -> List[Dict[str, Any]]:
"""
Create SARIF results from findings.
Args:
findings: List of findings
include_code_flows: Whether to include code flows
Returns:
List of SARIF result objects
"""
results = []
for finding in findings:
result = {
"ruleId": f"{finding.category}_{finding.severity}",
"level": self._severity_to_sarif_level(finding.severity),
"message": {
"text": finding.description
},
"locations": []
}
# Add location information if available
if finding.file_path:
location = {
"physicalLocation": {
"artifactLocation": {
"uri": finding.file_path,
"uriBaseId": "WORKSPACE"
}
}
}
# Add line information if available
if finding.line_start:
location["physicalLocation"]["region"] = {
"startLine": finding.line_start
}
if finding.line_end:
location["physicalLocation"]["region"]["endLine"] = finding.line_end
# Add code snippet if available
if finding.code_snippet:
location["physicalLocation"]["region"]["snippet"] = {
"text": finding.code_snippet
}
result["locations"].append(location)
# Add fix suggestions if available
if finding.recommendation:
result["fixes"] = [
{
"description": {
"text": finding.recommendation
}
}
]
# Add properties
result["properties"] = {
"findingId": finding.id,
"title": finding.title,
"metadata": finding.metadata
}
results.append(result)
return results
def _severity_to_sarif_level(self, severity: str) -> str:
"""
Convert severity to SARIF level.
Args:
severity: Finding severity
Returns:
SARIF level string
"""
mapping = {
"critical": "error",
"high": "error",
"medium": "warning",
"low": "note",
"info": "none"
}
return mapping.get(severity.lower(), "warning")
def _generate_report_summary(self, findings: List[ModuleFinding]) -> Dict[str, Any]:
"""
Generate summary statistics for the report.
Args:
findings: List of findings
Returns:
Summary dictionary
"""
severity_counts = {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"info": 0
}
category_counts = {}
affected_files = set()
for finding in findings:
# Count by severity
if finding.severity in severity_counts:
severity_counts[finding.severity] += 1
# Count by category
if finding.category not in category_counts:
category_counts[finding.category] = 0
category_counts[finding.category] += 1
# Track affected files
if finding.file_path:
affected_files.add(finding.file_path)
return {
"total_findings": len(findings),
"severity_distribution": severity_counts,
"category_distribution": category_counts,
"affected_files": len(affected_files),
"report_format": "SARIF 2.1.0",
"generated_at": datetime.utcnow().isoformat()
}
@@ -0,0 +1,14 @@
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
from .file_scanner import FileScanner
__all__ = ["FileScanner"]
@@ -0,0 +1,315 @@
"""
File Scanner Module - Scans and enumerates files in the workspace
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import logging
import mimetypes
from pathlib import Path
from typing import Dict, Any, List
import hashlib
try:
from toolbox.modules.base import BaseModule, ModuleMetadata, ModuleResult, ModuleFinding
except ImportError:
try:
from modules.base import BaseModule, ModuleMetadata, ModuleResult, ModuleFinding
except ImportError:
from src.toolbox.modules.base import BaseModule, ModuleMetadata, ModuleResult, ModuleFinding
logger = logging.getLogger(__name__)
class FileScanner(BaseModule):
"""
Scans files in the mounted workspace and collects information.
This module:
- Enumerates files based on patterns
- Detects file types
- Calculates file hashes
- Identifies potentially sensitive files
"""
def get_metadata(self) -> ModuleMetadata:
"""Get module metadata"""
return ModuleMetadata(
name="file_scanner",
version="1.0.0",
description="Scans and enumerates files in the workspace",
author="FuzzForge Team",
category="scanner",
tags=["files", "enumeration", "discovery"],
input_schema={
"patterns": {
"type": "array",
"items": {"type": "string"},
"description": "File patterns to scan (e.g., ['*.py', '*.js'])",
"default": ["*"]
},
"max_file_size": {
"type": "integer",
"description": "Maximum file size to scan in bytes",
"default": 10485760 # 10MB
},
"check_sensitive": {
"type": "boolean",
"description": "Check for sensitive file patterns",
"default": True
},
"calculate_hashes": {
"type": "boolean",
"description": "Calculate SHA256 hashes for files",
"default": False
}
},
output_schema={
"findings": {
"type": "array",
"description": "List of discovered files with metadata"
}
},
requires_workspace=True
)
def validate_config(self, config: Dict[str, Any]) -> bool:
"""Validate module configuration"""
patterns = config.get("patterns", ["*"])
if not isinstance(patterns, list):
raise ValueError("patterns must be a list")
max_size = config.get("max_file_size", 10485760)
if not isinstance(max_size, int) or max_size <= 0:
raise ValueError("max_file_size must be a positive integer")
return True
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
"""
Execute the file scanning module.
Args:
config: Module configuration
workspace: Path to the workspace directory
Returns:
ModuleResult with file findings
"""
self.start_timer()
self.validate_workspace(workspace)
self.validate_config(config)
findings = []
file_count = 0
total_size = 0
file_types = {}
# Get configuration
patterns = config.get("patterns", ["*"])
max_file_size = config.get("max_file_size", 10485760)
check_sensitive = config.get("check_sensitive", True)
calculate_hashes = config.get("calculate_hashes", False)
logger.info(f"Scanning workspace with patterns: {patterns}")
try:
# Scan for each pattern
for pattern in patterns:
for file_path in workspace.rglob(pattern):
if not file_path.is_file():
continue
file_count += 1
relative_path = file_path.relative_to(workspace)
# Get file stats
try:
stats = file_path.stat()
file_size = stats.st_size
total_size += file_size
# Skip large files
if file_size > max_file_size:
logger.warning(f"Skipping large file: {relative_path} ({file_size} bytes)")
continue
# Detect file type
file_type = self._detect_file_type(file_path)
if file_type not in file_types:
file_types[file_type] = 0
file_types[file_type] += 1
# Check for sensitive files
if check_sensitive and self._is_sensitive_file(file_path):
findings.append(self.create_finding(
title=f"Potentially sensitive file: {relative_path.name}",
description=f"Found potentially sensitive file at {relative_path}",
severity="medium",
category="sensitive_file",
file_path=str(relative_path),
metadata={
"file_size": file_size,
"file_type": file_type
}
))
# Calculate hash if requested
file_hash = None
if calculate_hashes and file_size < 1048576: # Only hash files < 1MB
file_hash = self._calculate_hash(file_path)
# Create informational finding for each file
findings.append(self.create_finding(
title=f"File discovered: {relative_path.name}",
description=f"File: {relative_path}",
severity="info",
category="file_enumeration",
file_path=str(relative_path),
metadata={
"file_size": file_size,
"file_type": file_type,
"file_hash": file_hash
}
))
except Exception as e:
logger.error(f"Error processing file {relative_path}: {e}")
# Create summary
summary = {
"total_files": file_count,
"total_size_bytes": total_size,
"file_types": file_types,
"patterns_scanned": patterns
}
return self.create_result(
findings=findings,
status="success",
summary=summary,
metadata={
"workspace": str(workspace),
"config": config
}
)
except Exception as e:
logger.error(f"File scanner failed: {e}")
return self.create_result(
findings=findings,
status="failed",
error=str(e)
)
def _detect_file_type(self, file_path: Path) -> str:
"""
Detect the type of a file.
Args:
file_path: Path to the file
Returns:
File type string
"""
# Try to determine from extension
mime_type, _ = mimetypes.guess_type(str(file_path))
if mime_type:
return mime_type
# Check by extension
ext = file_path.suffix.lower()
type_map = {
'.py': 'text/x-python',
'.js': 'application/javascript',
'.java': 'text/x-java',
'.cpp': 'text/x-c++',
'.c': 'text/x-c',
'.go': 'text/x-go',
'.rs': 'text/x-rust',
'.rb': 'text/x-ruby',
'.php': 'text/x-php',
'.yaml': 'text/yaml',
'.yml': 'text/yaml',
'.json': 'application/json',
'.xml': 'text/xml',
'.md': 'text/markdown',
'.txt': 'text/plain',
'.sh': 'text/x-shellscript',
'.bat': 'text/x-batch',
'.ps1': 'text/x-powershell'
}
return type_map.get(ext, 'application/octet-stream')
def _is_sensitive_file(self, file_path: Path) -> bool:
"""
Check if a file might contain sensitive information.
Args:
file_path: Path to the file
Returns:
True if potentially sensitive
"""
sensitive_patterns = [
'.env',
'.env.local',
'.env.production',
'credentials',
'password',
'secret',
'private_key',
'id_rsa',
'id_dsa',
'.pem',
'.key',
'.pfx',
'.p12',
'wallet',
'.ssh',
'token',
'api_key',
'config.json',
'settings.json',
'.git-credentials',
'.npmrc',
'.pypirc',
'.docker/config.json'
]
file_name_lower = file_path.name.lower()
for pattern in sensitive_patterns:
if pattern in file_name_lower:
return True
return False
def _calculate_hash(self, file_path: Path) -> str:
"""
Calculate SHA256 hash of a file.
Args:
file_path: Path to the file
Returns:
Hex string of SHA256 hash
"""
try:
sha256_hash = hashlib.sha256()
with open(file_path, "rb") as f:
for byte_block in iter(lambda: f.read(4096), b""):
sha256_hash.update(byte_block)
return sha256_hash.hexdigest()
except Exception as e:
logger.error(f"Failed to calculate hash for {file_path}: {e}")
return None
@@ -0,0 +1,38 @@
"""
Static Analysis Security Testing (SAST) Modules
This package contains modules for static code analysis and security testing.
Available modules:
- CodeQL: GitHub's semantic code analysis engine
- SonarQube: Code quality and security analysis platform
- Snyk: Vulnerability scanning for dependencies and code
- OpenGrep: Open-source pattern-based static analysis tool
- Bandit: Python-specific security issue identifier
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
from typing import List, Type
from ..base import BaseModule
# Module registry for automatic discovery
STATIC_ANALYSIS_MODULES: List[Type[BaseModule]] = []
def register_module(module_class: Type[BaseModule]):
"""Register a static analysis module"""
STATIC_ANALYSIS_MODULES.append(module_class)
return module_class
def get_available_modules() -> List[Type[BaseModule]]:
"""Get all available static analysis modules"""
return STATIC_ANALYSIS_MODULES.copy()
@@ -0,0 +1,418 @@
"""
Bandit Static Analysis Module
This module uses Bandit to detect security vulnerabilities in Python code.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import asyncio
import json
from pathlib import Path
from typing import Dict, Any, List
import subprocess
import logging
from ..base import BaseModule, ModuleMetadata, ModuleFinding, ModuleResult
from . import register_module
logger = logging.getLogger(__name__)
@register_module
class BanditModule(BaseModule):
"""Bandit Python security analysis module"""
def get_metadata(self) -> ModuleMetadata:
"""Get module metadata"""
return ModuleMetadata(
name="bandit",
version="1.7.5",
description="Python-specific security issue identifier using Bandit",
author="FuzzForge Team",
category="static_analysis",
tags=["python", "sast", "security", "vulnerabilities"],
input_schema={
"type": "object",
"properties": {
"confidence": {
"type": "string",
"enum": ["LOW", "MEDIUM", "HIGH"],
"default": "LOW",
"description": "Minimum confidence level for reported issues"
},
"severity": {
"type": "string",
"enum": ["LOW", "MEDIUM", "HIGH"],
"default": "LOW",
"description": "Minimum severity level for reported issues"
},
"tests": {
"type": "array",
"items": {"type": "string"},
"description": "Specific test IDs to run"
},
"skips": {
"type": "array",
"items": {"type": "string"},
"description": "Test IDs to skip"
},
"exclude_dirs": {
"type": "array",
"items": {"type": "string"},
"default": ["tests", "test", ".git", "__pycache__"],
"description": "Directories to exclude from analysis"
},
"include_patterns": {
"type": "array",
"items": {"type": "string"},
"default": ["*.py"],
"description": "File patterns to include"
},
"aggregate": {
"type": "string",
"enum": ["file", "vuln"],
"default": "file",
"description": "How to aggregate results"
},
"context_lines": {
"type": "integer",
"default": 3,
"description": "Number of context lines to show"
}
}
},
output_schema={
"type": "object",
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"test_id": {"type": "string"},
"test_name": {"type": "string"},
"confidence": {"type": "string"},
"severity": {"type": "string"},
"file_path": {"type": "string"},
"line_number": {"type": "integer"}
}
}
}
}
}
)
def validate_config(self, config: Dict[str, Any]) -> bool:
"""Validate configuration"""
confidence = config.get("confidence", "LOW")
# Handle both string and list formats
if isinstance(confidence, list):
confidence = confidence[0] if confidence else "MEDIUM"
if confidence not in ["LOW", "MEDIUM", "HIGH"]:
raise ValueError("confidence must be LOW, MEDIUM, or HIGH")
severity = config.get("severity", "LOW")
# Handle both string and list formats
if isinstance(severity, list):
severity = severity[0] if severity else "MEDIUM"
if severity not in ["LOW", "MEDIUM", "HIGH"]:
raise ValueError("severity must be LOW, MEDIUM, or HIGH")
context_lines = config.get("context_lines", 3)
if not isinstance(context_lines, int) or context_lines < 0 or context_lines > 10:
raise ValueError("context_lines must be between 0 and 10")
return True
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
"""Execute Bandit security analysis"""
self.start_timer()
try:
# Validate inputs
self.validate_config(config)
self.validate_workspace(workspace)
logger.info(f"Running Bandit analysis on {workspace}")
# Check if there are any Python files
python_files = list(workspace.rglob("*.py"))
if not python_files:
logger.info("No Python files found for Bandit analysis")
return self.create_result(
findings=[],
status="success",
summary={"total_findings": 0, "files_scanned": 0}
)
# Build bandit command
cmd = ["bandit", "-f", "json"]
# Add confidence level
confidence = config.get("confidence", "LOW")
# Handle both string and list formats
if isinstance(confidence, list):
confidence = confidence[0] if confidence else "MEDIUM"
cmd.extend(["--confidence-level", self._get_confidence_levels(confidence)])
# Add severity level
severity = config.get("severity", "LOW")
# Handle both string and list formats
if isinstance(severity, list):
severity = severity[0] if severity else "MEDIUM"
cmd.extend(["--severity-level", self._get_severity_levels(severity)])
# Add tests to run
if config.get("tests"):
cmd.extend(["-t", ",".join(config["tests"])])
# Add tests to skip
if config.get("skips"):
cmd.extend(["-s", ",".join(config["skips"])])
# Add exclude directories
exclude_dirs = config.get("exclude_dirs", ["tests", "test", ".git", "__pycache__"])
if exclude_dirs:
cmd.extend(["-x", ",".join(exclude_dirs)])
# Add aggregate mode
aggregate = config.get("aggregate", "file")
cmd.extend(["-a", aggregate])
# Add context lines
context_lines = config.get("context_lines", 3)
cmd.extend(["-n", str(context_lines)])
# Add recursive flag and target
cmd.extend(["-r", str(workspace)])
logger.debug(f"Running command: {' '.join(cmd)}")
# Run Bandit
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=workspace
)
stdout, stderr = await process.communicate()
# Parse results
findings = []
if process.returncode in [0, 1]: # 0 = no issues, 1 = issues found
findings = self._parse_bandit_output(stdout.decode(), workspace)
else:
error_msg = stderr.decode()
logger.error(f"Bandit failed: {error_msg}")
return self.create_result(
findings=[],
status="failed",
error=f"Bandit execution failed: {error_msg}"
)
# Create summary
summary = self._create_summary(findings, len(python_files))
logger.info(f"Bandit found {len(findings)} security issues")
return self.create_result(
findings=findings,
status="success",
summary=summary
)
except Exception as e:
logger.error(f"Bandit module failed: {e}")
return self.create_result(
findings=[],
status="failed",
error=str(e)
)
def _get_confidence_levels(self, min_confidence: str) -> str:
"""Get minimum confidence level for Bandit"""
return min_confidence.lower()
def _get_severity_levels(self, min_severity: str) -> str:
"""Get minimum severity level for Bandit"""
return min_severity.lower()
def _parse_bandit_output(self, output: str, workspace: Path) -> List[ModuleFinding]:
"""Parse Bandit JSON output into findings"""
findings = []
if not output.strip():
return findings
try:
data = json.loads(output)
results = data.get("results", [])
for result in results:
# Extract information
test_id = result.get("test_id", "unknown")
test_name = result.get("test_name", "")
issue_confidence = result.get("issue_confidence", "MEDIUM")
issue_severity = result.get("issue_severity", "MEDIUM")
issue_text = result.get("issue_text", "")
# File location
filename = result.get("filename", "")
line_number = result.get("line_number", 0)
line_range = result.get("line_range", [])
# Code context
code = result.get("code", "")
# Make file path relative to workspace
if filename:
try:
rel_path = Path(filename).relative_to(workspace)
filename = str(rel_path)
except ValueError:
pass
# Map Bandit severity to our levels
finding_severity = self._map_severity(issue_severity)
# Determine category based on test_id
category = self._get_category(test_id, test_name)
# Create finding
finding = self.create_finding(
title=f"Python security issue: {test_name}",
description=issue_text or f"Bandit test {test_id} detected a security issue",
severity=finding_severity,
category=category,
file_path=filename if filename else None,
line_start=line_number if line_number > 0 else None,
line_end=line_range[-1] if line_range and len(line_range) > 1 else None,
code_snippet=code.strip() if code else None,
recommendation=self._get_recommendation(test_id, test_name),
metadata={
"test_id": test_id,
"test_name": test_name,
"bandit_confidence": issue_confidence,
"bandit_severity": issue_severity,
"line_range": line_range,
"more_info": result.get("more_info", "")
}
)
findings.append(finding)
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse Bandit output: {e}")
except Exception as e:
logger.warning(f"Error processing Bandit results: {e}")
return findings
def _map_severity(self, bandit_severity: str) -> str:
"""Map Bandit severity to our standard severity levels"""
severity_map = {
"HIGH": "high",
"MEDIUM": "medium",
"LOW": "low"
}
return severity_map.get(bandit_severity.upper(), "medium")
def _get_category(self, test_id: str, test_name: str) -> str:
"""Determine finding category based on Bandit test"""
# Map common Bandit test categories
if "sql" in test_id.lower() or "injection" in test_name.lower():
return "injection"
elif "crypto" in test_id.lower() or "hash" in test_name.lower():
return "cryptography"
elif "shell" in test_id.lower() or "subprocess" in test_name.lower():
return "command_injection"
elif "hardcode" in test_id.lower() or "password" in test_name.lower():
return "hardcoded_secrets"
elif "pickle" in test_id.lower() or "deserial" in test_name.lower():
return "deserialization"
elif "request" in test_id.lower() or "http" in test_name.lower():
return "web_security"
elif "random" in test_id.lower():
return "weak_randomness"
elif "path" in test_id.lower() or "traversal" in test_name.lower():
return "path_traversal"
else:
return "python_security"
def _get_recommendation(self, test_id: str, test_name: str) -> str:
"""Generate recommendation based on Bandit test"""
recommendations = {
# SQL Injection
"B608": "Use parameterized queries instead of string formatting for SQL queries.",
"B703": "Use parameterized queries with Django ORM or raw SQL.",
# Cryptography
"B101": "Remove hardcoded passwords and use secure configuration management.",
"B105": "Remove hardcoded passwords and use environment variables or secret management.",
"B106": "Remove hardcoded passwords from function arguments.",
"B107": "Remove hardcoded passwords from default function arguments.",
"B303": "Use cryptographically secure hash functions like SHA-256 or better.",
"B324": "Use strong cryptographic algorithms instead of deprecated ones.",
"B413": "Use secure encryption algorithms and proper key management.",
# Command Injection
"B602": "Validate and sanitize input before using in subprocess calls.",
"B603": "Avoid using subprocess with shell=True. Use array form instead.",
"B605": "Avoid starting processes with shell=True.",
# Deserialization
"B301": "Avoid using pickle for untrusted data. Use JSON or safer alternatives.",
"B302": "Avoid using marshal for untrusted data.",
"B506": "Use safe YAML loading methods like yaml.safe_load().",
# Web Security
"B501": "Validate SSL certificates in requests to prevent MITM attacks.",
"B401": "Import and use telnetlib carefully, prefer SSH for remote connections.",
# Random
"B311": "Use cryptographically secure random generators like secrets module.",
# Path Traversal
"B108": "Validate file paths to prevent directory traversal attacks."
}
return recommendations.get(test_id,
f"Review the {test_name} security issue and apply appropriate security measures.")
def _create_summary(self, findings: List[ModuleFinding], total_files: int) -> Dict[str, Any]:
"""Create analysis summary"""
severity_counts = {"high": 0, "medium": 0, "low": 0}
category_counts = {}
test_counts = {}
for finding in findings:
# Count by severity
severity_counts[finding.severity] += 1
# Count by category
category = finding.category
category_counts[category] = category_counts.get(category, 0) + 1
# Count by test
test_id = finding.metadata.get("test_id", "unknown")
test_counts[test_id] = test_counts.get(test_id, 0) + 1
return {
"total_findings": len(findings),
"files_scanned": total_files,
"severity_counts": severity_counts,
"category_counts": category_counts,
"top_tests": dict(sorted(test_counts.items(), key=lambda x: x[1], reverse=True)[:10]),
"files_with_issues": len(set(f.file_path for f in findings if f.file_path))
}
@@ -0,0 +1,396 @@
"""
OpenGrep Static Analysis Module
This module uses OpenGrep (open-source version of Semgrep) for pattern-based
static analysis across multiple programming languages.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import asyncio
import json
import tempfile
from pathlib import Path
from typing import Dict, Any, List
import subprocess
import logging
from ..base import BaseModule, ModuleMetadata, ModuleFinding, ModuleResult
from . import register_module
logger = logging.getLogger(__name__)
@register_module
class OpenGrepModule(BaseModule):
"""OpenGrep static analysis module"""
def get_metadata(self) -> ModuleMetadata:
"""Get module metadata"""
return ModuleMetadata(
name="opengrep",
version="1.45.0",
description="Open-source pattern-based static analysis tool for security vulnerabilities",
author="FuzzForge Team",
category="static_analysis",
tags=["sast", "pattern-matching", "multi-language", "security"],
input_schema={
"type": "object",
"properties": {
"config": {
"type": "string",
"enum": ["auto", "p/security-audit", "p/owasp-top-ten", "p/cwe-top-25"],
"default": "auto",
"description": "Rule configuration to use"
},
"languages": {
"type": "array",
"items": {"type": "string"},
"description": "Specific languages to analyze"
},
"include_patterns": {
"type": "array",
"items": {"type": "string"},
"description": "File patterns to include"
},
"exclude_patterns": {
"type": "array",
"items": {"type": "string"},
"description": "File patterns to exclude"
},
"max_target_bytes": {
"type": "integer",
"default": 1000000,
"description": "Maximum file size to analyze (bytes)"
},
"timeout": {
"type": "integer",
"default": 300,
"description": "Analysis timeout in seconds"
},
"severity": {
"type": "array",
"items": {"type": "string", "enum": ["ERROR", "WARNING", "INFO"]},
"default": ["ERROR", "WARNING", "INFO"],
"description": "Minimum severity levels to report"
},
"confidence": {
"type": "array",
"items": {"type": "string", "enum": ["HIGH", "MEDIUM", "LOW"]},
"default": ["HIGH", "MEDIUM", "LOW"],
"description": "Minimum confidence levels to report"
}
}
},
output_schema={
"type": "object",
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"rule_id": {"type": "string"},
"severity": {"type": "string"},
"confidence": {"type": "string"},
"file_path": {"type": "string"},
"line_number": {"type": "integer"}
}
}
}
}
}
)
def validate_config(self, config: Dict[str, Any]) -> bool:
"""Validate configuration"""
timeout = config.get("timeout", 300)
if not isinstance(timeout, int) or timeout < 30 or timeout > 3600:
raise ValueError("Timeout must be between 30 and 3600 seconds")
max_bytes = config.get("max_target_bytes", 1000000)
if not isinstance(max_bytes, int) or max_bytes < 1000 or max_bytes > 10000000:
raise ValueError("max_target_bytes must be between 1000 and 10000000")
return True
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
"""Execute OpenGrep static analysis"""
self.start_timer()
try:
# Validate inputs
self.validate_config(config)
self.validate_workspace(workspace)
logger.info(f"Running OpenGrep analysis on {workspace}")
# Build opengrep command
cmd = ["semgrep", "--json"]
# Add configuration
config_type = config.get("config", "auto")
if config_type == "auto":
cmd.extend(["--config", "auto"])
else:
cmd.extend(["--config", config_type])
# Add timeout
cmd.extend(["--timeout", str(config.get("timeout", 300))])
# Add max target bytes
cmd.extend(["--max-target-bytes", str(config.get("max_target_bytes", 1000000))])
# Add languages if specified
if config.get("languages"):
for lang in config["languages"]:
cmd.extend(["--lang", lang])
# Add include patterns
if config.get("include_patterns"):
for pattern in config["include_patterns"]:
cmd.extend(["--include", pattern])
# Add exclude patterns
if config.get("exclude_patterns"):
for pattern in config["exclude_patterns"]:
cmd.extend(["--exclude", pattern])
# Add severity filter (semgrep only accepts one severity level)
severity_levels = config.get("severity", ["ERROR", "WARNING", "INFO"])
if severity_levels:
# Use the highest severity level from the list
severity_priority = {"ERROR": 3, "WARNING": 2, "INFO": 1}
highest_severity = max(severity_levels, key=lambda x: severity_priority.get(x, 0))
cmd.extend(["--severity", highest_severity])
# Add confidence filter (if supported in this version)
confidence_levels = config.get("confidence", ["HIGH", "MEDIUM"])
if confidence_levels and len(confidence_levels) < 3: # Only if not all levels
# Note: confidence filtering might need to be done post-processing
pass
# Disable metrics collection
cmd.append("--disable-version-check")
cmd.append("--no-git-ignore")
# Add target directory
cmd.append(str(workspace))
logger.debug(f"Running command: {' '.join(cmd)}")
# Run OpenGrep
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=workspace
)
stdout, stderr = await process.communicate()
# Parse results
findings = []
if process.returncode in [0, 1]: # 0 = no findings, 1 = findings found
findings = self._parse_opengrep_output(stdout.decode(), workspace, config)
else:
error_msg = stderr.decode()
logger.error(f"OpenGrep failed: {error_msg}")
return self.create_result(
findings=[],
status="failed",
error=f"OpenGrep execution failed: {error_msg}"
)
# Create summary
summary = self._create_summary(findings)
logger.info(f"OpenGrep found {len(findings)} potential issues")
return self.create_result(
findings=findings,
status="success",
summary=summary
)
except Exception as e:
logger.error(f"OpenGrep module failed: {e}")
return self.create_result(
findings=[],
status="failed",
error=str(e)
)
def _parse_opengrep_output(self, output: str, workspace: Path, config: Dict[str, Any]) -> List[ModuleFinding]:
"""Parse OpenGrep JSON output into findings"""
findings = []
if not output.strip():
return findings
try:
data = json.loads(output)
results = data.get("results", [])
# Get filtering criteria
allowed_severities = set(config.get("severity", ["ERROR", "WARNING", "INFO"]))
allowed_confidences = set(config.get("confidence", ["HIGH", "MEDIUM", "LOW"]))
for result in results:
# Extract basic info
rule_id = result.get("check_id", "unknown")
message = result.get("message", "")
severity = result.get("extra", {}).get("severity", "INFO").upper()
# File location info
path_info = result.get("path", "")
start_line = result.get("start", {}).get("line", 0)
end_line = result.get("end", {}).get("line", 0)
start_col = result.get("start", {}).get("col", 0)
end_col = result.get("end", {}).get("col", 0)
# Code snippet
lines = result.get("extra", {}).get("lines", "")
# Metadata
metadata = result.get("extra", {})
cwe = metadata.get("metadata", {}).get("cwe", [])
owasp = metadata.get("metadata", {}).get("owasp", [])
confidence = metadata.get("metadata", {}).get("confidence", "MEDIUM").upper()
# Apply severity filter
if severity not in allowed_severities:
continue
# Apply confidence filter
if confidence not in allowed_confidences:
continue
# Make file path relative to workspace
if path_info:
try:
rel_path = Path(path_info).relative_to(workspace)
path_info = str(rel_path)
except ValueError:
pass
# Map severity to our standard levels
finding_severity = self._map_severity(severity)
# Create finding
finding = self.create_finding(
title=f"Security issue: {rule_id}",
description=message or f"OpenGrep rule {rule_id} triggered",
severity=finding_severity,
category=self._get_category(rule_id, metadata),
file_path=path_info if path_info else None,
line_start=start_line if start_line > 0 else None,
line_end=end_line if end_line > 0 and end_line != start_line else None,
code_snippet=lines.strip() if lines else None,
recommendation=self._get_recommendation(rule_id, metadata),
metadata={
"rule_id": rule_id,
"opengrep_severity": severity,
"confidence": confidence,
"cwe": cwe,
"owasp": owasp,
"fix": metadata.get("fix", ""),
"impact": metadata.get("impact", ""),
"likelihood": metadata.get("likelihood", ""),
"references": metadata.get("references", [])
}
)
findings.append(finding)
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse OpenGrep output: {e}")
except Exception as e:
logger.warning(f"Error processing OpenGrep results: {e}")
return findings
def _map_severity(self, opengrep_severity: str) -> str:
"""Map OpenGrep severity to our standard severity levels"""
severity_map = {
"ERROR": "high",
"WARNING": "medium",
"INFO": "low"
}
return severity_map.get(opengrep_severity.upper(), "medium")
def _get_category(self, rule_id: str, metadata: Dict[str, Any]) -> str:
"""Determine finding category based on rule and metadata"""
cwe_list = metadata.get("metadata", {}).get("cwe", [])
owasp_list = metadata.get("metadata", {}).get("owasp", [])
# Check for common security categories
if any("injection" in rule_id.lower() for x in [rule_id]):
return "injection"
elif any("xss" in rule_id.lower() for x in [rule_id]):
return "xss"
elif any("csrf" in rule_id.lower() for x in [rule_id]):
return "csrf"
elif any("auth" in rule_id.lower() for x in [rule_id]):
return "authentication"
elif any("crypto" in rule_id.lower() for x in [rule_id]):
return "cryptography"
elif cwe_list:
return f"cwe-{cwe_list[0]}"
elif owasp_list:
return f"owasp-{owasp_list[0].replace(' ', '-').lower()}"
else:
return "security"
def _get_recommendation(self, rule_id: str, metadata: Dict[str, Any]) -> str:
"""Generate recommendation based on rule and metadata"""
fix_suggestion = metadata.get("fix", "")
if fix_suggestion:
return fix_suggestion
# Generic recommendations based on rule type
if "injection" in rule_id.lower():
return "Use parameterized queries or prepared statements to prevent injection attacks."
elif "xss" in rule_id.lower():
return "Properly encode/escape user input before displaying it in web pages."
elif "crypto" in rule_id.lower():
return "Use cryptographically secure algorithms and proper key management."
elif "hardcode" in rule_id.lower():
return "Remove hardcoded secrets and use secure configuration management."
else:
return "Review this security issue and apply appropriate fixes based on your security requirements."
def _create_summary(self, findings: List[ModuleFinding]) -> Dict[str, Any]:
"""Create analysis summary"""
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
category_counts = {}
rule_counts = {}
for finding in findings:
# Count by severity
severity_counts[finding.severity] += 1
# Count by category
category = finding.category
category_counts[category] = category_counts.get(category, 0) + 1
# Count by rule
rule_id = finding.metadata.get("rule_id", "unknown")
rule_counts[rule_id] = rule_counts.get(rule_id, 0) + 1
return {
"total_findings": len(findings),
"severity_counts": severity_counts,
"category_counts": category_counts,
"top_rules": dict(sorted(rule_counts.items(), key=lambda x: x[1], reverse=True)[:10]),
"files_analyzed": len(set(f.file_path for f in findings if f.file_path))
}
+11
View File
@@ -0,0 +1,11 @@
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
@@ -0,0 +1,12 @@
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
+187
View File
@@ -0,0 +1,187 @@
"""
Manual Workflow Registry for Prefect Deployment
This file contains the manual registry of all workflows that can be deployed.
Developers MUST add their workflows here after creating them.
This approach is required because:
1. Prefect cannot deploy dynamically imported flows
2. Docker deployment needs static flow references
3. Explicit registration provides better control and visibility
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
from typing import Dict, Any, Callable
import logging
logger = logging.getLogger(__name__)
# Import only essential workflows
# Import each workflow individually to handle failures gracefully
security_assessment_flow = None
secret_detection_flow = None
# Try to import each workflow individually
try:
from .security_assessment.workflow import main_flow as security_assessment_flow
except ImportError as e:
logger.warning(f"Failed to import security_assessment workflow: {e}")
try:
from .comprehensive.secret_detection_scan.workflow import main_flow as secret_detection_flow
except ImportError as e:
logger.warning(f"Failed to import secret_detection_scan workflow: {e}")
# Manual registry - developers add workflows here after creation
# Only include workflows that were successfully imported
WORKFLOW_REGISTRY: Dict[str, Dict[str, Any]] = {}
# Add workflows that were successfully imported
if security_assessment_flow is not None:
WORKFLOW_REGISTRY["security_assessment"] = {
"flow": security_assessment_flow,
"module_path": "toolbox.workflows.security_assessment.workflow",
"function_name": "main_flow",
"description": "Comprehensive security assessment workflow that scans files, analyzes code for vulnerabilities, and generates SARIF reports",
"version": "1.0.0",
"author": "FuzzForge Team",
"tags": ["security", "scanner", "analyzer", "static-analysis", "sarif"]
}
if secret_detection_flow is not None:
WORKFLOW_REGISTRY["secret_detection_scan"] = {
"flow": secret_detection_flow,
"module_path": "toolbox.workflows.comprehensive.secret_detection_scan.workflow",
"function_name": "main_flow",
"description": "Comprehensive secret detection using TruffleHog and Gitleaks for thorough credential scanning",
"version": "1.0.0",
"author": "FuzzForge Team",
"tags": ["secrets", "credentials", "detection", "trufflehog", "gitleaks", "comprehensive"]
}
#
# To add a new workflow, follow this pattern:
#
# "my_new_workflow": {
# "flow": my_new_flow_function, # Import the flow function above
# "module_path": "toolbox.workflows.my_new_workflow.workflow",
# "function_name": "my_new_flow_function",
# "description": "Description of what this workflow does",
# "version": "1.0.0",
# "author": "Developer Name",
# "tags": ["tag1", "tag2"]
# }
def get_workflow_flow(workflow_name: str) -> Callable:
"""
Get the flow function for a workflow.
Args:
workflow_name: Name of the workflow
Returns:
Flow function
Raises:
KeyError: If workflow not found in registry
"""
if workflow_name not in WORKFLOW_REGISTRY:
available = list(WORKFLOW_REGISTRY.keys())
raise KeyError(
f"Workflow '{workflow_name}' not found in registry. "
f"Available workflows: {available}. "
f"Please add the workflow to toolbox/workflows/registry.py"
)
return WORKFLOW_REGISTRY[workflow_name]["flow"]
def get_workflow_info(workflow_name: str) -> Dict[str, Any]:
"""
Get registry information for a workflow.
Args:
workflow_name: Name of the workflow
Returns:
Registry information dictionary
Raises:
KeyError: If workflow not found in registry
"""
if workflow_name not in WORKFLOW_REGISTRY:
available = list(WORKFLOW_REGISTRY.keys())
raise KeyError(
f"Workflow '{workflow_name}' not found in registry. "
f"Available workflows: {available}"
)
return WORKFLOW_REGISTRY[workflow_name]
def list_registered_workflows() -> Dict[str, Dict[str, Any]]:
"""
Get all registered workflows.
Returns:
Dictionary of all workflow registry entries
"""
return WORKFLOW_REGISTRY.copy()
def validate_registry() -> bool:
"""
Validate the workflow registry for consistency.
Returns:
True if valid, raises exceptions if not
Raises:
ValueError: If registry is invalid
"""
if not WORKFLOW_REGISTRY:
raise ValueError("Workflow registry is empty")
required_fields = ["flow", "module_path", "function_name", "description"]
for name, entry in WORKFLOW_REGISTRY.items():
# Check required fields
missing_fields = [field for field in required_fields if field not in entry]
if missing_fields:
raise ValueError(
f"Workflow '{name}' missing required fields: {missing_fields}"
)
# Check if flow is callable
if not callable(entry["flow"]):
raise ValueError(f"Workflow '{name}' flow is not callable")
# Check if flow has the required Prefect attributes
if not hasattr(entry["flow"], "deploy"):
raise ValueError(
f"Workflow '{name}' flow is not a Prefect flow (missing deploy method)"
)
logger.info(f"Registry validation passed. {len(WORKFLOW_REGISTRY)} workflows registered.")
return True
# Validate registry on import
try:
validate_registry()
logger.info(f"Workflow registry loaded successfully with {len(WORKFLOW_REGISTRY)} workflows")
except Exception as e:
logger.error(f"Workflow registry validation failed: {e}")
raise
@@ -0,0 +1,30 @@
FROM prefecthq/prefect:3-python3.11
WORKDIR /app
# Create toolbox directory structure to match expected import paths
RUN mkdir -p /app/toolbox/workflows /app/toolbox/modules
# Copy base module infrastructure
COPY modules/__init__.py /app/toolbox/modules/
COPY modules/base.py /app/toolbox/modules/
# Copy only required modules (manual selection)
COPY modules/scanner /app/toolbox/modules/scanner
COPY modules/analyzer /app/toolbox/modules/analyzer
COPY modules/reporter /app/toolbox/modules/reporter
# Copy this workflow
COPY workflows/security_assessment /app/toolbox/workflows/security_assessment
# Install workflow-specific requirements if they exist
RUN if [ -f /app/toolbox/workflows/security_assessment/requirements.txt ]; then pip install --no-cache-dir -r /app/toolbox/workflows/security_assessment/requirements.txt; fi
# Install common requirements
RUN pip install --no-cache-dir pyyaml
# Set Python path
ENV PYTHONPATH=/app:$PYTHONPATH
# Create workspace directory
RUN mkdir -p /workspace
@@ -0,0 +1,11 @@
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
@@ -0,0 +1,111 @@
name: security_assessment
version: "2.0.0"
description: "Comprehensive security assessment workflow that scans files, analyzes code for vulnerabilities, and generates SARIF reports"
author: "FuzzForge Team"
category: "comprehensive"
tags:
- "security"
- "scanner"
- "analyzer"
- "static-analysis"
- "sarif"
- "comprehensive"
supported_volume_modes:
- "ro"
- "rw"
default_volume_mode: "ro"
default_target_path: "/workspace"
requirements:
tools:
- "file_scanner"
- "security_analyzer"
- "sarif_reporter"
resources:
memory: "512Mi"
cpu: "500m"
timeout: 1800
has_docker: true
default_parameters:
target_path: "/workspace"
volume_mode: "ro"
scanner_config: {}
analyzer_config: {}
reporter_config: {}
parameters:
type: object
properties:
target_path:
type: string
default: "/workspace"
description: "Path to analyze"
volume_mode:
type: string
enum: ["ro", "rw"]
default: "ro"
description: "Volume mount mode"
scanner_config:
type: object
description: "File scanner configuration"
properties:
patterns:
type: array
items:
type: string
description: "File patterns to scan"
check_sensitive:
type: boolean
description: "Check for sensitive files"
calculate_hashes:
type: boolean
description: "Calculate file hashes"
max_file_size:
type: integer
description: "Maximum file size to scan (bytes)"
analyzer_config:
type: object
description: "Security analyzer configuration"
properties:
file_extensions:
type: array
items:
type: string
description: "File extensions to analyze"
check_secrets:
type: boolean
description: "Check for hardcoded secrets"
check_sql:
type: boolean
description: "Check for SQL injection risks"
check_dangerous_functions:
type: boolean
description: "Check for dangerous function calls"
reporter_config:
type: object
description: "SARIF reporter configuration"
properties:
include_code_flows:
type: boolean
description: "Include code flow information"
output_schema:
type: object
properties:
sarif:
type: object
description: "SARIF-formatted security findings"
summary:
type: object
description: "Scan execution summary"
properties:
total_findings:
type: integer
severity_counts:
type: object
tool_counts:
type: object
@@ -0,0 +1,4 @@
# Requirements for security assessment workflow
pydantic>=2.0.0
pyyaml>=6.0
aiofiles>=23.0.0
@@ -0,0 +1,252 @@
"""
Security Assessment Workflow - Comprehensive security analysis using multiple modules
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import sys
import logging
from pathlib import Path
from typing import Dict, Any, Optional
from prefect import flow, task
import json
# Add modules to path
sys.path.insert(0, '/app')
# Import modules
from toolbox.modules.scanner import FileScanner
from toolbox.modules.analyzer import SecurityAnalyzer
from toolbox.modules.reporter import SARIFReporter
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@task(name="file_scanning")
async def scan_files_task(workspace: Path, config: Dict[str, Any]) -> Dict[str, Any]:
"""
Task to scan files in the workspace.
Args:
workspace: Path to the workspace
config: Scanner configuration
Returns:
Scanner results
"""
logger.info(f"Starting file scanning in {workspace}")
scanner = FileScanner()
result = await scanner.execute(config, workspace)
logger.info(f"File scanning completed: {result.summary.get('total_files', 0)} files found")
return result.dict()
@task(name="security_analysis")
async def analyze_security_task(workspace: Path, config: Dict[str, Any]) -> Dict[str, Any]:
"""
Task to analyze security vulnerabilities.
Args:
workspace: Path to the workspace
config: Analyzer configuration
Returns:
Analysis results
"""
logger.info("Starting security analysis")
analyzer = SecurityAnalyzer()
result = await analyzer.execute(config, workspace)
logger.info(
f"Security analysis completed: {result.summary.get('total_findings', 0)} findings"
)
return result.dict()
@task(name="report_generation")
async def generate_report_task(
scan_results: Dict[str, Any],
analysis_results: Dict[str, Any],
config: Dict[str, Any],
workspace: Path
) -> Dict[str, Any]:
"""
Task to generate SARIF report from all findings.
Args:
scan_results: Results from scanner
analysis_results: Results from analyzer
config: Reporter configuration
workspace: Path to the workspace
Returns:
SARIF report
"""
logger.info("Generating SARIF report")
reporter = SARIFReporter()
# Combine findings from all modules
all_findings = []
# Add scanner findings (only sensitive files, not all files)
scanner_findings = scan_results.get("findings", [])
sensitive_findings = [f for f in scanner_findings if f.get("severity") != "info"]
all_findings.extend(sensitive_findings)
# Add analyzer findings
analyzer_findings = analysis_results.get("findings", [])
all_findings.extend(analyzer_findings)
# Prepare reporter config
reporter_config = {
**config,
"findings": all_findings,
"tool_name": "FuzzForge Security Assessment",
"tool_version": "1.0.0"
}
result = await reporter.execute(reporter_config, workspace)
# Extract SARIF from result
sarif = result.dict().get("sarif", {})
logger.info(f"Report generated with {len(all_findings)} total findings")
return sarif
@flow(name="security_assessment", log_prints=True)
async def main_flow(
target_path: str = "/workspace",
volume_mode: str = "ro",
scanner_config: Optional[Dict[str, Any]] = None,
analyzer_config: Optional[Dict[str, Any]] = None,
reporter_config: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Main security assessment workflow.
This workflow:
1. Scans files in the workspace
2. Analyzes code for security vulnerabilities
3. Generates a SARIF report with all findings
Args:
target_path: Path to the mounted workspace (default: /workspace)
volume_mode: Volume mount mode (ro/rw)
scanner_config: Configuration for file scanner
analyzer_config: Configuration for security analyzer
reporter_config: Configuration for SARIF reporter
Returns:
SARIF-formatted findings report
"""
logger.info(f"Starting security assessment workflow")
logger.info(f"Workspace: {target_path}, Mode: {volume_mode}")
# Set workspace path
workspace = Path(target_path)
if not workspace.exists():
logger.error(f"Workspace does not exist: {workspace}")
return {
"error": f"Workspace not found: {workspace}",
"sarif": None
}
# Default configurations
if not scanner_config:
scanner_config = {
"patterns": ["*"],
"check_sensitive": True,
"calculate_hashes": False,
"max_file_size": 10485760 # 10MB
}
if not analyzer_config:
analyzer_config = {
"file_extensions": [".py", ".js", ".java", ".php", ".rb", ".go"],
"check_secrets": True,
"check_sql": True,
"check_dangerous_functions": True
}
if not reporter_config:
reporter_config = {
"include_code_flows": False
}
try:
# Execute workflow tasks
logger.info("Phase 1: File scanning")
scan_results = await scan_files_task(workspace, scanner_config)
logger.info("Phase 2: Security analysis")
analysis_results = await analyze_security_task(workspace, analyzer_config)
logger.info("Phase 3: Report generation")
sarif_report = await generate_report_task(
scan_results,
analysis_results,
reporter_config,
workspace
)
# Log summary
if sarif_report and "runs" in sarif_report:
results_count = len(sarif_report["runs"][0].get("results", []))
logger.info(f"Workflow completed successfully with {results_count} findings")
else:
logger.info("Workflow completed successfully")
return sarif_report
except Exception as e:
logger.error(f"Workflow failed: {e}")
# Return error in SARIF format
return {
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
"version": "2.1.0",
"runs": [
{
"tool": {
"driver": {
"name": "FuzzForge Security Assessment",
"version": "1.0.0"
}
},
"results": [],
"invocations": [
{
"executionSuccessful": False,
"exitCode": 1,
"exitCodeDescription": str(e)
}
]
}
]
}
if __name__ == "__main__":
# For local testing
import asyncio
asyncio.run(main_flow(
target_path="/tmp/test",
scanner_config={"patterns": ["*.py"]},
analyzer_config={"check_secrets": True}
))
+64
View File
@@ -0,0 +1,64 @@
# FuzzForge CLI specific .gitignore
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual environments
.venv/
venv/
ENV/
env/
# UV package manager - keep uv.lock for CLI
# uv.lock # Commented out - we want to keep this for reproducible CLI builds
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Testing
.coverage
.pytest_cache/
.tox/
htmlcov/
# MyPy
.mypy_cache/
# Local development
local_config.yaml
.env.local
# Generated files
*.log
*.tmp
# CLI specific
# Don't ignore uv.lock in CLI as it's needed for reproducible builds
!uv.lock
+621
View File
@@ -0,0 +1,621 @@
# FuzzForge CLI
🛡️ **FuzzForge CLI** - Command-line interface for FuzzForge security testing platform
A comprehensive CLI for managing security testing workflows, monitoring runs in real-time, and analyzing findings with beautiful terminal interfaces and persistent project management.
## ✨ Features
- 📁 **Project Management** - Initialize and manage FuzzForge projects with local databases
- 🔧 **Workflow Management** - Browse, configure, and run security testing workflows
- 🚀 **Workflow Execution** - Execute and manage security testing workflows
- 🔍 **Findings Analysis** - View, export, and analyze security findings in multiple formats
- 📊 **Real-time Monitoring** - Live dashboards for fuzzing statistics and crash reports
- ⚙️ **Configuration** - Flexible project and global configuration management
- 🎨 **Rich UI** - Beautiful tables, progress bars, and interactive prompts
- 💾 **Persistent Storage** - SQLite database for runs, findings, and crash data
- 🛡️ **Error Handling** - Comprehensive error handling with user-friendly messages
- 🔄 **Network Resilience** - Automatic retries and graceful degradation
## 🚀 Quick Start
### Installation
#### Prerequisites
- Python 3.11 or higher
- [uv](https://docs.astral.sh/uv/) package manager
#### Install FuzzForge CLI
```bash
# Clone the repository
git clone https://github.com/FuzzingLabs/fuzzforge_alpha.git
cd fuzzforge_alpha/cli
# Install globally with uv (recommended)
uv tool install .
# Alternative: Install in development mode
uv sync
uv add --editable ../sdk
uv tool install --editable .
# Verify installation
fuzzforge --help
```
#### Shell Completion (Optional)
```bash
# Install completion for your shell
fuzzforge --install-completion
```
### Initialize Your First Project
```bash
# Create a new project directory
mkdir my-security-project
cd my-security-project
# Initialize FuzzForge project
ff init
# Check status
fuzzforge status
```
This creates a `.fuzzforge/` directory with:
- SQLite database for persistent storage
- Configuration file (`config.yaml`)
- Project metadata
### Run Your First Analysis
```bash
# List available workflows
fuzzforge workflows list
# Get workflow details
fuzzforge workflows info security_assessment
# Submit a workflow for analysis
fuzzforge workflow security_assessment /path/to/your/code
# Monitor progress in real-time
fuzzforge monitor live <execution-id>
# View findings when complete
fuzzforge finding <execution-id>
```
## 📚 Command Reference
### Project Management
#### `ff init`
Initialize a new FuzzForge project in the current directory.
```bash
ff init --name "My Security Project" --api-url "http://localhost:8000"
```
**Options:**
- `--name, -n` - Project name (defaults to directory name)
- `--api-url, -u` - FuzzForge API URL (defaults to http://localhost:8000)
- `--force, -f` - Force initialization even if project exists
#### `fuzzforge status`
Show comprehensive project and API status information.
```bash
fuzzforge status
```
Displays:
- Project information and configuration
- Database statistics (runs, findings, crashes)
- API connectivity and available workflows
### Workflow Management
#### `fuzzforge workflows list`
List all available security testing workflows.
```bash
fuzzforge workflows list
```
#### `fuzzforge workflows info <workflow-name>`
Show detailed information about a specific workflow.
```bash
fuzzforge workflows info security_assessment
```
Displays:
- Workflow metadata (version, author, description)
- Parameter schema and requirements
- Supported volume modes and features
#### `fuzzforge workflows parameters <workflow-name>`
Interactive parameter builder for workflows.
```bash
# Interactive mode
fuzzforge workflows parameters security_assessment
# Save parameters to file
fuzzforge workflows parameters security_assessment --output params.json
# Non-interactive mode (show schema only)
fuzzforge workflows parameters security_assessment --no-interactive
```
### Workflow Execution
#### `fuzzforge workflow <workflow> <target-path>`
Execute a security testing workflow.
```bash
# Basic execution
fuzzforge workflow security_assessment /path/to/code
# With parameters
fuzzforge workflow security_assessment /path/to/binary \
--param timeout=3600 \
--param iterations=10000
# With parameter file
fuzzforge workflow security_assessment /path/to/code \
--param-file my-params.json
# Wait for completion
fuzzforge workflow security_assessment /path/to/code --wait
```
**Options:**
- `--param, -p` - Parameter in key=value format (can be used multiple times)
- `--param-file, -f` - JSON file containing parameters
- `--volume-mode, -v` - Volume mount mode: `ro` (read-only) or `rw` (read-write)
- `--timeout, -t` - Execution timeout in seconds
- `--interactive/--no-interactive, -i/-n` - Interactive parameter input
- `--wait, -w` - Wait for execution to complete
- `--live, -l` - Show live monitoring during execution
#### `fuzzforge workflow status [execution-id]`
Check the status of a workflow execution.
```bash
# Check specific execution
fuzzforge workflow status abc123def456
# Check most recent execution
fuzzforge workflow status
```
#### `fuzzforge workflow history`
Show workflow execution history from local database.
```bash
# List all executions
fuzzforge workflow history
# Filter by workflow
fuzzforge workflow history --workflow security_assessment
# Filter by status
fuzzforge workflow history --status completed
# Limit results
fuzzforge workflow history --limit 10
```
#### `fuzzforge workflow retry <execution-id>`
Retry a workflow with the same or modified parameters.
```bash
# Retry with same parameters
fuzzforge workflow retry abc123def456
# Modify parameters interactively
fuzzforge workflow retry abc123def456 --modify-params
```
### Findings Management
#### `fuzzforge finding [execution-id]`
View security findings for a specific execution.
```bash
# Display latest findings
fuzzforge finding
# Display specific execution findings
fuzzforge finding abc123def456
```
#### `fuzzforge findings`
Browse all security findings from local database.
```bash
# List all findings
fuzzforge findings
# Show findings history
fuzzforge findings history --limit 20
```
#### `fuzzforge finding export [execution-id]`
Export security findings in various formats.
```bash
# Export latest findings
fuzzforge finding export --format json
# Export specific execution findings
fuzzforge finding export abc123def456 --format sarif
# Export as CSV with output file
fuzzforge finding export abc123def456 --format csv --output report.csv
# Export as HTML report
fuzzforge finding export --format html --output report.html
```
### Real-time Monitoring
#### `fuzzforge monitor stats <execution-id>`
Show current fuzzing statistics.
```bash
# Show stats once
fuzzforge monitor stats abc123def456 --once
# Live updating stats (default)
fuzzforge monitor stats abc123def456 --refresh 5
```
#### `fuzzforge monitor crashes <run-id>`
Display crash reports for a fuzzing run.
```bash
fuzzforge monitor crashes abc123def456 --limit 50
```
#### `fuzzforge monitor live <run-id>`
Real-time monitoring dashboard with live updates.
```bash
fuzzforge monitor live abc123def456 --refresh 3
```
Features:
- Live updating statistics
- Progress indicators and bars
- Run status monitoring
- Automatic completion detection
### Configuration Management
#### `fuzzforge config show`
Display current configuration settings.
```bash
# Show project configuration
fuzzforge config show
# Show global configuration
fuzzforge config show --global
```
#### `fuzzforge config set <key> <value>`
Set a configuration value.
```bash
# Project settings
fuzzforge config set project.api_url "http://api.fuzzforge.com"
fuzzforge config set project.default_timeout 7200
fuzzforge config set project.default_workflow "security_assessment"
# Retention settings
fuzzforge config set retention.max_runs 200
fuzzforge config set retention.keep_findings_days 120
# Preferences
fuzzforge config set preferences.auto_save_findings true
fuzzforge config set preferences.show_progress_bars false
# Global configuration
fuzzforge config set project.api_url "http://global.api.com" --global
```
#### `fuzzforge config get <key>`
Get a specific configuration value.
```bash
fuzzforge config get project.api_url
fuzzforge config get retention.max_runs --global
```
#### `fuzzforge config reset`
Reset configuration to defaults.
```bash
# Reset project configuration
fuzzforge config reset
# Reset global configuration
fuzzforge config reset --global
# Skip confirmation
fuzzforge config reset --force
```
#### `fuzzforge config edit`
Open configuration file in default editor.
```bash
# Edit project configuration
fuzzforge config edit
# Edit global configuration
fuzzforge config edit --global
```
## 🏗️ Project Structure
When you initialize a FuzzForge project, the following structure is created:
```
my-project/
├── .fuzzforge/
│ ├── config.yaml # Project configuration
│ └── findings.db # SQLite database
├── .gitignore # Updated with FuzzForge entries
└── README.md # Project README (if created)
```
### Database Schema
The SQLite database stores:
- **runs** - Workflow run history and metadata
- **findings** - Security findings and SARIF data
- **crashes** - Crash reports and fuzzing data
### Configuration Format
Project configuration (`.fuzzforge/config.yaml`):
```yaml
project:
name: "My Security Project"
api_url: "http://localhost:8000"
default_timeout: 3600
default_workflow: null
retention:
max_runs: 100
keep_findings_days: 90
preferences:
auto_save_findings: true
show_progress_bars: true
table_style: "rich"
color_output: true
```
## 🔧 Advanced Usage
### Parameter Handling
FuzzForge CLI supports flexible parameter input:
1. **Command line parameters**:
```bash
ff workflow workflow-name /path key1=value1 key2=value2
```
2. **Parameter files**:
```bash
echo '{"timeout": 3600, "threads": 4}' > params.json
ff workflow workflow-name /path --param-file params.json
```
3. **Interactive prompts**:
```bash
ff workflow workflow-name /path --interactive
```
4. **Parameter builder**:
```bash
ff workflows parameters workflow-name --output my-params.json
ff workflow workflow-name /path --param-file my-params.json
```
### Environment Variables
Override configuration with environment variables:
```bash
export FUZZFORGE_API_URL="http://production.api.com"
export FUZZFORGE_TIMEOUT="7200"
```
### Data Retention
Configure automatic cleanup of old data:
```bash
# Keep only 50 runs
fuzzforge config set retention.max_runs 50
# Keep findings for 30 days
fuzzforge config set retention.keep_findings_days 30
```
### Export Formats
Support for multiple export formats:
- **JSON** - Simplified findings structure
- **CSV** - Tabular data for spreadsheets
- **HTML** - Interactive web report
- **SARIF** - Standard security analysis format
## 🛠️ Development
### Setup Development Environment
```bash
# Clone repository
git clone https://github.com/FuzzingLabs/fuzzforge_alpha.git
cd fuzzforge_alpha/cli
# Install in development mode
uv sync
uv add --editable ../sdk
# Install CLI in editable mode
uv tool install --editable .
```
### Project Structure
```
cli/
├── src/fuzzforge_cli/
│ ├── __init__.py
│ ├── main.py # Main CLI app
│ ├── config.py # Configuration management
│ ├── database.py # Database operations
│ ├── exceptions.py # Error handling
│ ├── api_validation.py # API response validation
│ └── commands/ # Command implementations
│ ├── init.py # Project initialization
│ ├── workflows.py # Workflow management
│ ├── runs.py # Run management
│ ├── findings.py # Findings management
│ ├── monitor.py # Real-time monitoring
│ ├── config.py # Configuration commands
│ └── status.py # Status information
├── pyproject.toml # Project configuration
└── README.md # This file
```
### Running Tests
```bash
# Run tests (when available)
uv run pytest
# Code formatting
uv run black src/
uv run isort src/
# Type checking
uv run mypy src/
```
## ⚠️ Troubleshooting
### Common Issues
#### "No FuzzForge project found"
```bash
# Initialize a project first
ff init
```
#### API Connection Failed
```bash
# Check API URL configuration
fuzzforge config get project.api_url
# Test API connectivity
fuzzforge status
# Update API URL if needed
fuzzforge config set project.api_url "http://correct-url:8000"
```
#### Permission Errors
```bash
# Ensure proper permissions for project directory
chmod -R 755 .fuzzforge/
# Check file ownership
ls -la .fuzzforge/
```
#### Database Issues
```bash
# Check database file exists
ls -la .fuzzforge/findings.db
# Reinitialize if corrupted (will lose data)
rm .fuzzforge/findings.db
ff init --force
```
### Environment Variables
Set these environment variables for debugging:
```bash
export FUZZFORGE_DEBUG=1 # Enable debug logging
export FUZZFORGE_API_URL="..." # Override API URL
export FUZZFORGE_TIMEOUT="30" # Override timeout
```
### Getting Help
```bash
# General help
fuzzforge --help
# Command-specific help
ff workflows --help
ff workflow run --help
ff monitor live --help
# Show version
fuzzforge --version
```
## 🏆 Example Workflow
Here's a complete example of analyzing a project:
```bash
# 1. Initialize project
mkdir my-security-audit
cd my-security-audit
ff init --name "Security Audit 2024"
# 2. Check available workflows
fuzzforge workflows list
# 3. Submit comprehensive security assessment
ff workflow security_assessment /path/to/source/code --wait
# 4. View findings in table format
fuzzforge findings get <run-id>
# 5. Export detailed report
fuzzforge findings export <run-id> --format html --output security_report.html
# 6. Check project statistics
fuzzforge status
```
## 📜 License
This project is licensed under the terms specified in the main FuzzForge repository.
## 🤝 Contributing
Contributions are welcome! Please see the main FuzzForge repository for contribution guidelines.
---
**FuzzForge CLI** - Making security testing workflows accessible and efficient from the command line.
+323
View File
@@ -0,0 +1,323 @@
#!/usr/bin/env python3
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
"""
Install shell completion for FuzzForge CLI.
This script installs completion using Typer's built-in --install-completion command.
"""
import os
import sys
import subprocess
from pathlib import Path
import typer
def run_fuzzforge_completion_install(shell: str) -> bool:
"""Install completion using the fuzzforge CLI itself."""
try:
# Use the CLI's built-in completion installation
result = subprocess.run([
sys.executable, "-m", "fuzzforge_cli.main",
"--install-completion", shell
], capture_output=True, text=True, cwd=Path(__file__).parent.parent)
if result.returncode == 0:
print(f"{shell.capitalize()} completion installed successfully")
return True
else:
print(f"❌ Failed to install {shell} completion: {result.stderr}")
return False
except Exception as e:
print(f"❌ Error installing {shell} completion: {e}")
return False
def create_manual_completion_scripts():
"""Create manual completion scripts as fallback."""
scripts = {
"bash": '''
# FuzzForge CLI completion for bash
_fuzzforge_completion() {
local IFS=$'\\t'
local response
response=$(env COMP_WORDS="${COMP_WORDS[*]}" COMP_CWORD=$COMP_CWORD _FUZZFORGE_COMPLETE=bash_complete $1)
for completion in $response; do
IFS=',' read type value <<< "$completion"
if [[ $type == 'dir' ]]; then
COMPREPLY=()
compopt -o dirnames
elif [[ $type == 'file' ]]; then
COMPREPLY=()
compopt -o default
elif [[ $type == 'plain' ]]; then
COMPREPLY+=($value)
fi
done
return 0
}
complete -o nosort -F _fuzzforge_completion fuzzforge
''',
"zsh": '''
#compdef fuzzforge
_fuzzforge_completion() {
local -a completions
local -a completions_with_descriptions
local -a response
response=(${(f)"$(env COMP_WORDS="${words[*]}" COMP_CWORD=$((CURRENT-1)) _FUZZFORGE_COMPLETE=zsh_complete fuzzforge)"})
for type_and_line in $response; do
if [[ "$type_and_line" =~ ^([^,]*),(.*)$ ]]; then
local type="$match[1]"
local line="$match[2]"
if [[ "$type" == "dir" ]]; then
_path_files -/
elif [[ "$type" == "file" ]]; then
_path_files -f
elif [[ "$type" == "plain" ]]; then
if [[ "$line" =~ ^([^:]*):(.*)$ ]]; then
completions_with_descriptions+=("$match[1]":"$match[2]")
else
completions+=("$line")
fi
fi
fi
done
if [ -n "$completions_with_descriptions" ]; then
_describe "" completions_with_descriptions -V unsorted
fi
if [ -n "$completions" ]; then
compadd -U -V unsorted -a completions
fi
}
compdef _fuzzforge_completion fuzzforge;
''',
"fish": '''
# FuzzForge CLI completion for fish
function __fuzzforge_completion
set -l response
for value in (env _FUZZFORGE_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) fuzzforge)
set response $response $value
end
for completion in $response
set -l metadata (string split "," $completion)
if test $metadata[1] = "dir"
__fish_complete_directories $metadata[2]
else if test $metadata[1] = "file"
__fish_complete_path $metadata[2]
else if test $metadata[1] = "plain"
echo $metadata[2]
end
end
end
complete --no-files --command fuzzforge --arguments "(__fuzzforge_completion)"
'''
}
return scripts
def install_bash_completion():
"""Install bash completion."""
print("📝 Installing bash completion...")
# Get the manual completion script
scripts = create_manual_completion_scripts()
completion_script = scripts["bash"]
# Try different locations for bash completion
completion_dirs = [
Path.home() / ".bash_completion.d",
Path("/usr/local/etc/bash_completion.d"),
Path("/etc/bash_completion.d")
]
for completion_dir in completion_dirs:
try:
completion_dir.mkdir(exist_ok=True)
completion_file = completion_dir / "fuzzforge"
completion_file.write_text(completion_script)
print(f"✅ Bash completion installed to: {completion_file}")
# Add source line to .bashrc if not present
bashrc = Path.home() / ".bashrc"
source_line = f"source {completion_file}"
if bashrc.exists():
bashrc_content = bashrc.read_text()
if source_line not in bashrc_content:
with bashrc.open("a") as f:
f.write(f"\n# FuzzForge CLI completion\n{source_line}\n")
print("✅ Added completion source to ~/.bashrc")
return True
except PermissionError:
continue
except Exception as e:
print(f"❌ Failed to install bash completion: {e}")
continue
print("❌ Could not install bash completion (permission denied)")
return False
def install_zsh_completion():
"""Install zsh completion."""
print("📝 Installing zsh completion...")
# Get the manual completion script
scripts = create_manual_completion_scripts()
completion_script = scripts["zsh"]
# Create completion directory
comp_dir = Path.home() / ".zsh" / "completions"
comp_dir.mkdir(parents=True, exist_ok=True)
try:
completion_file = comp_dir / "_fuzzforge"
completion_file.write_text(completion_script)
print(f"✅ Zsh completion installed to: {completion_file}")
# Add fpath to .zshrc if not present
zshrc = Path.home() / ".zshrc"
fpath_line = f'fpath=(~/.zsh/completions $fpath)'
autoload_line = 'autoload -U compinit && compinit'
if zshrc.exists():
zshrc_content = zshrc.read_text()
lines_to_add = []
if fpath_line not in zshrc_content:
lines_to_add.append(fpath_line)
if autoload_line not in zshrc_content:
lines_to_add.append(autoload_line)
if lines_to_add:
with zshrc.open("a") as f:
f.write(f"\n# FuzzForge CLI completion\n")
for line in lines_to_add:
f.write(f"{line}\n")
print("✅ Added completion setup to ~/.zshrc")
return True
except Exception as e:
print(f"❌ Failed to install zsh completion: {e}")
return False
def install_fish_completion():
"""Install fish completion."""
print("📝 Installing fish completion...")
# Get the manual completion script
scripts = create_manual_completion_scripts()
completion_script = scripts["fish"]
# Fish completion directory
comp_dir = Path.home() / ".config" / "fish" / "completions"
comp_dir.mkdir(parents=True, exist_ok=True)
try:
completion_file = comp_dir / "fuzzforge.fish"
completion_file.write_text(completion_script)
print(f"✅ Fish completion installed to: {completion_file}")
return True
except Exception as e:
print(f"❌ Failed to install fish completion: {e}")
return False
def detect_shell():
"""Detect the current shell."""
shell_path = os.environ.get('SHELL', '')
if 'bash' in shell_path:
return 'bash'
elif 'zsh' in shell_path:
return 'zsh'
elif 'fish' in shell_path:
return 'fish'
else:
return None
def main():
"""Install completion for the current shell or all shells."""
print("🚀 FuzzForge CLI Completion Installer")
print("=" * 50)
current_shell = detect_shell()
if current_shell:
print(f"🐚 Detected shell: {current_shell}")
# Check for command line arguments
if len(sys.argv) > 1 and sys.argv[1] == "--all":
install_all = True
print("Installing completion for all shells...")
else:
# Ask user which shells to install (with default to current shell only)
if current_shell:
install_all = typer.confirm("Install completion for all supported shells (bash, zsh, fish)?", default=False)
if not install_all:
print(f"Installing completion for {current_shell} only...")
else:
install_all = typer.confirm("Install completion for all supported shells (bash, zsh, fish)?", default=True)
success_count = 0
if install_all or current_shell == 'bash':
if install_bash_completion():
success_count += 1
if install_all or current_shell == 'zsh':
if install_zsh_completion():
success_count += 1
if install_all or current_shell == 'fish':
if install_fish_completion():
success_count += 1
print("\n" + "=" * 50)
if success_count > 0:
print(f"✅ Successfully installed completion for {success_count} shell(s)!")
print("\n📋 To activate completion:")
print(" • Bash: Restart your terminal or run 'source ~/.bashrc'")
print(" • Zsh: Restart your terminal or run 'source ~/.zshrc'")
print(" • Fish: Completion is active immediately")
print("\n💡 Try typing 'fuzzforge <TAB>' to test completion!")
else:
print("❌ No completions were installed successfully.")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+22
View File
@@ -0,0 +1,22 @@
"""
FuzzForge CLI - Command-line interface for FuzzForge security testing platform.
This module provides the main entry point for the FuzzForge CLI application.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import typer
from src.fuzzforge_cli.main import app
if __name__ == "__main__":
app()
+41
View File
@@ -0,0 +1,41 @@
[project]
name = "fuzzforge-cli"
version = "0.6.0"
description = "FuzzForge CLI - Command-line interface for FuzzForge security testing platform"
readme = "README.md"
authors = [
{ name = "Tanguy Duhamel", email = "tduhamel@fuzzinglabs.com" }
]
requires-python = ">=3.11"
dependencies = [
"typer>=0.12.0",
"rich>=13.0.0",
"pyyaml>=6.0.0",
"pydantic>=2.0.0",
"httpx>=0.27.0",
"websockets>=13.0",
"sseclient-py>=1.8.0",
"fuzzforge-sdk",
"fuzzforge-ai",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"black>=24.0.0",
"isort>=5.13.0",
"mypy>=1.11.0",
]
[project.scripts]
fuzzforge = "fuzzforge_cli.main:main"
ff = "fuzzforge_cli.main:main"
[build-system]
requires = ["uv_build>=0.8.17,<0.9.0"]
build-backend = "uv_build"
[tool.uv.sources]
fuzzforge-sdk = { path = "../sdk", editable = true }
fuzzforge-ai = { path = "../ai", editable = true }
+19
View File
@@ -0,0 +1,19 @@
"""
FuzzForge CLI - Command-line interface for FuzzForge security testing platform.
A comprehensive CLI for managing workflows, runs, findings, and real-time monitoring
with local project management and persistent storage.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
__version__ = "0.6.0"
+311
View File
@@ -0,0 +1,311 @@
"""
API response validation and graceful degradation utilities.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import logging
from typing import Any, Dict, List, Optional, Union
from pydantic import BaseModel, ValidationError as PydanticValidationError
from .exceptions import ValidationError, APIConnectionError
logger = logging.getLogger(__name__)
class WorkflowMetadata(BaseModel):
"""Expected workflow metadata structure"""
name: str
version: str
author: Optional[str] = None
description: Optional[str] = None
parameters: Dict[str, Any] = {}
supported_volume_modes: List[str] = ["ro", "rw"]
class RunStatus(BaseModel):
"""Expected run status structure"""
run_id: str
workflow: str
status: str
created_at: str
updated_at: str
@property
def is_completed(self) -> bool:
"""Check if run is in a completed state"""
return self.status.lower() in ["completed", "success", "finished"]
@property
def is_running(self) -> bool:
"""Check if run is currently running"""
return self.status.lower() in ["running", "in_progress", "active"]
@property
def is_failed(self) -> bool:
"""Check if run has failed"""
return self.status.lower() in ["failed", "error", "cancelled"]
class FindingsResponse(BaseModel):
"""Expected findings response structure"""
run_id: str
sarif: Dict[str, Any]
total_issues: Optional[int] = None
def model_post_init(self, __context: Any) -> None:
"""Validate SARIF structure after initialization"""
if not self.sarif.get("runs"):
logger.warning(f"SARIF data for run {self.run_id} missing 'runs' section")
elif not isinstance(self.sarif["runs"], list):
logger.warning(f"SARIF 'runs' section is not a list for run {self.run_id}")
def validate_api_response(response_data: Any, expected_model: type[BaseModel],
operation: str = "API operation") -> BaseModel:
"""
Validate API response against expected Pydantic model.
Args:
response_data: Raw response data from API
expected_model: Pydantic model class to validate against
operation: Description of the operation for error messages
Returns:
Validated model instance
Raises:
ValidationError: If validation fails
"""
try:
return expected_model.model_validate(response_data)
except PydanticValidationError as e:
logger.error(f"API response validation failed for {operation}: {e}")
raise ValidationError(
f"API response for {operation}",
str(response_data)[:200] + "..." if len(str(response_data)) > 200 else str(response_data),
f"valid {expected_model.__name__} format"
) from e
except Exception as e:
logger.error(f"Unexpected error validating API response for {operation}: {e}")
raise ValidationError(
f"API response for {operation}",
"invalid data",
f"valid {expected_model.__name__} format"
) from e
def validate_sarif_structure(sarif_data: Dict[str, Any]) -> Dict[str, str]:
"""
Validate basic SARIF structure and return validation issues.
Args:
sarif_data: SARIF data dictionary
Returns:
Dictionary of validation issues found
"""
issues = {}
# Check basic SARIF structure
if not isinstance(sarif_data, dict):
issues["structure"] = "SARIF data is not a dictionary"
return issues
if "runs" not in sarif_data:
issues["runs"] = "Missing 'runs' section in SARIF data"
elif not isinstance(sarif_data["runs"], list):
issues["runs_type"] = "'runs' section is not a list"
elif len(sarif_data["runs"]) == 0:
issues["runs_empty"] = "'runs' section is empty"
else:
# Check first run structure
run = sarif_data["runs"][0]
if not isinstance(run, dict):
issues["run_structure"] = "First run is not a dictionary"
else:
if "results" not in run:
issues["results"] = "Missing 'results' section in run"
elif not isinstance(run["results"], list):
issues["results_type"] = "'results' section is not a list"
if "tool" not in run:
issues["tool"] = "Missing 'tool' section in run"
elif not isinstance(run["tool"], dict):
issues["tool_type"] = "'tool' section is not a dictionary"
return issues
def safe_extract_sarif_summary(sarif_data: Dict[str, Any]) -> Dict[str, Any]:
"""
Safely extract summary information from SARIF data with fallbacks.
Args:
sarif_data: SARIF data dictionary
Returns:
Summary dictionary with safe defaults
"""
summary = {
"total_issues": 0,
"by_severity": {},
"by_rule": {},
"tools": [],
"validation_issues": []
}
# Validate structure first
validation_issues = validate_sarif_structure(sarif_data)
if validation_issues:
summary["validation_issues"] = list(validation_issues.values())
logger.warning(f"SARIF validation issues: {validation_issues}")
try:
runs = sarif_data.get("runs", [])
if not runs:
return summary
run = runs[0]
results = run.get("results", [])
summary["total_issues"] = len(results)
# Count by severity/level
for result in results:
try:
level = result.get("level", "note")
rule_id = result.get("ruleId", "unknown")
summary["by_severity"][level] = summary["by_severity"].get(level, 0) + 1
summary["by_rule"][rule_id] = summary["by_rule"].get(rule_id, 0) + 1
except Exception as e:
logger.warning(f"Failed to process result: {e}")
continue
# Extract tool information safely
try:
tool = run.get("tool", {})
driver = tool.get("driver", {})
if driver.get("name"):
summary["tools"].append({
"name": driver.get("name", "unknown"),
"version": driver.get("version", "unknown"),
"rules": len(driver.get("rules", []))
})
except Exception as e:
logger.warning(f"Failed to extract tool information: {e}")
except Exception as e:
logger.error(f"Failed to extract SARIF summary: {e}")
summary["validation_issues"].append(f"Summary extraction failed: {e}")
return summary
def validate_workflow_parameters(parameters: Dict[str, Any],
workflow_schema: Dict[str, Any]) -> List[str]:
"""
Validate workflow parameters against schema with detailed error messages.
Args:
parameters: Parameters to validate
workflow_schema: JSON schema for the workflow
Returns:
List of validation error messages
"""
errors = []
try:
properties = workflow_schema.get("properties", {})
required = set(workflow_schema.get("required", []))
# Check required parameters
missing_required = required - set(parameters.keys())
if missing_required:
errors.append(f"Missing required parameters: {', '.join(missing_required)}")
# Validate individual parameters
for param_name, param_value in parameters.items():
if param_name not in properties:
errors.append(f"Unknown parameter: {param_name}")
continue
param_schema = properties[param_name]
param_type = param_schema.get("type", "string")
# Type validation
if param_type == "integer" and not isinstance(param_value, int):
errors.append(f"Parameter '{param_name}' must be an integer")
elif param_type == "number" and not isinstance(param_value, (int, float)):
errors.append(f"Parameter '{param_name}' must be a number")
elif param_type == "boolean" and not isinstance(param_value, bool):
errors.append(f"Parameter '{param_name}' must be a boolean")
elif param_type == "array" and not isinstance(param_value, list):
errors.append(f"Parameter '{param_name}' must be an array")
# Range validation for numbers
if param_type in ["integer", "number"] and isinstance(param_value, (int, float)):
minimum = param_schema.get("minimum")
maximum = param_schema.get("maximum")
if minimum is not None and param_value < minimum:
errors.append(f"Parameter '{param_name}' must be >= {minimum}")
if maximum is not None and param_value > maximum:
errors.append(f"Parameter '{param_name}' must be <= {maximum}")
except Exception as e:
logger.error(f"Parameter validation failed: {e}")
errors.append(f"Parameter validation error: {e}")
return errors
def create_fallback_response(response_type: str, **kwargs) -> Dict[str, Any]:
"""
Create fallback responses when API calls fail.
Args:
response_type: Type of response to create
**kwargs: Additional data for the fallback
Returns:
Fallback response dictionary
"""
fallbacks = {
"workflow_list": {
"workflows": [],
"message": "Unable to fetch workflows from API"
},
"run_status": {
"run_id": kwargs.get("run_id", "unknown"),
"workflow": kwargs.get("workflow", "unknown"),
"status": "unknown",
"created_at": kwargs.get("created_at", "unknown"),
"updated_at": kwargs.get("updated_at", "unknown"),
"message": "Unable to fetch run status from API"
},
"findings": {
"run_id": kwargs.get("run_id", "unknown"),
"sarif": {
"version": "2.1.0",
"runs": []
},
"message": "Unable to fetch findings from API"
}
}
fallback = fallbacks.get(response_type, {"message": f"No fallback available for {response_type}"})
logger.info(f"Using fallback response for {response_type}: {fallback.get('message', 'Unknown fallback')}")
return fallback
@@ -0,0 +1,14 @@
"""
Command modules for FuzzForge CLI.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
+133
View File
@@ -0,0 +1,133 @@
"""AI integration commands for the FuzzForge CLI."""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
from __future__ import annotations
import asyncio
import os
from datetime import datetime
from typing import Optional
import typer
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from ..config import ProjectConfigManager
console = Console()
app = typer.Typer(name="ai", help="Interact with the FuzzForge AI system")
@app.command("agent")
def ai_agent() -> None:
"""Launch the full AI agent CLI with A2A orchestration."""
console.print("[cyan]🤖 Opening Project FuzzForge AI Agent session[/cyan]\n")
try:
from fuzzforge_ai.cli import FuzzForgeCLI
cli = FuzzForgeCLI()
asyncio.run(cli.run())
except ImportError as exc:
console.print(f"[red]Failed to import AI CLI:[/red] {exc}")
console.print("[dim]Ensure AI dependencies are installed (pip install -e .)[/dim]")
raise typer.Exit(1) from exc
except Exception as exc: # pragma: no cover - runtime safety
console.print(f"[red]Failed to launch AI agent:[/red] {exc}")
console.print("[dim]Check that .env contains LITELLM_MODEL and API keys[/dim]")
raise typer.Exit(1) from exc
# Memory + health commands
@app.command("status")
def ai_status() -> None:
"""Show AI system health and configuration."""
try:
status = asyncio.run(get_ai_status_async())
except Exception as exc: # pragma: no cover
console.print(f"[red]Failed to get AI status:[/red] {exc}")
raise typer.Exit(1) from exc
console.print("[bold cyan]🤖 FuzzForge AI System Status[/bold cyan]\n")
config_table = Table(title="Configuration", show_header=True, header_style="bold magenta")
config_table.add_column("Setting", style="bold")
config_table.add_column("Value", style="cyan")
config_table.add_column("Status", style="green")
for key, info in status["config"].items():
status_icon = "" if info["configured"] else ""
display_value = info["value"] if info["value"] else "-"
config_table.add_row(key, display_value, f"{status_icon}")
console.print(config_table)
console.print()
components_table = Table(title="AI Components", show_header=True, header_style="bold magenta")
components_table.add_column("Component", style="bold")
components_table.add_column("Status", style="green")
components_table.add_column("Details", style="dim")
for component, info in status["components"].items():
status_icon = "🟢" if info["available"] else "🔴"
components_table.add_row(component, status_icon, info["details"])
console.print(components_table)
if status["agents"]:
console.print()
console.print(f"[bold green]✓[/bold green] {len(status['agents'])} agents registered")
@app.command("server")
def ai_server(
port: int = typer.Option(10100, "--port", "-p", help="Server port (default: 10100)"),
) -> None:
"""Start AI system as an A2A server."""
console.print(f"[cyan]🚀 Starting FuzzForge AI Server on port {port}[/cyan]")
console.print("[dim]Other agents can register this instance at the A2A endpoint[/dim]\n")
try:
os.environ["FUZZFORGE_PORT"] = str(port)
from fuzzforge_ai.__main__ import main as start_server
start_server()
except Exception as exc: # pragma: no cover
console.print(f"[red]Failed to start AI server:[/red] {exc}")
raise typer.Exit(1) from exc
# ---------------------------------------------------------------------------
# Helper functions (largely adapted from the OSS implementation)
# ---------------------------------------------------------------------------
@app.callback(invoke_without_command=True)
def ai_callback(ctx: typer.Context):
"""
🤖 AI integration features
"""
# Check if a subcommand is being invoked
if ctx.invoked_subcommand is not None:
# Let the subcommand handle it
return
# Show not implemented message for default command
console.print("🚧 [yellow]AI command is not fully implemented yet.[/yellow]")
console.print("Please use specific subcommands:")
console.print(" • [cyan]ff ai agent[/cyan] - Launch the full AI agent CLI")
console.print(" • [cyan]ff ai status[/cyan] - Show AI system health and configuration")
console.print(" • [cyan]ff ai server[/cyan] - Start AI system as an A2A server")
+384
View File
@@ -0,0 +1,384 @@
"""
Configuration management commands.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import typer
from pathlib import Path
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.prompt import Prompt, Confirm
from rich import box
from typing import Optional
from ..config import (
get_project_config,
ensure_project_config,
get_global_config,
save_global_config,
FuzzForgeConfig
)
from ..exceptions import require_project, ValidationError, handle_error
console = Console()
app = typer.Typer()
@app.command("show")
def show_config(
global_config: bool = typer.Option(
False, "--global", "-g",
help="Show global configuration instead of project config"
)
):
"""
📋 Display current configuration settings
"""
if global_config:
config = get_global_config()
config_type = "Global"
config_path = Path.home() / ".config" / "fuzzforge" / "config.yaml"
else:
try:
require_project()
config = get_project_config()
if not config:
raise ValidationError("project configuration", "missing", "initialized project")
except Exception as e:
handle_error(e, "loading project configuration")
return # Unreachable, but makes static analysis happy
config_type = "Project"
config_path = Path.cwd() / ".fuzzforge" / "config.yaml"
console.print(f"\n⚙️ [bold]{config_type} Configuration[/bold]\n")
# Project settings
project_table = Table(show_header=False, box=box.SIMPLE)
project_table.add_column("Setting", style="bold cyan")
project_table.add_column("Value")
project_table.add_row("Project Name", config.project.name)
project_table.add_row("API URL", config.project.api_url)
project_table.add_row("Default Timeout", f"{config.project.default_timeout}s")
if config.project.default_workflow:
project_table.add_row("Default Workflow", config.project.default_workflow)
console.print(
Panel.fit(
project_table,
title="📁 Project Settings",
box=box.ROUNDED
)
)
# Retention settings
retention_table = Table(show_header=False, box=box.SIMPLE)
retention_table.add_column("Setting", style="bold cyan")
retention_table.add_column("Value")
retention_table.add_row("Max Runs", str(config.retention.max_runs))
retention_table.add_row("Keep Findings (days)", str(config.retention.keep_findings_days))
console.print(
Panel.fit(
retention_table,
title="🗄️ Data Retention",
box=box.ROUNDED
)
)
# Preferences
prefs_table = Table(show_header=False, box=box.SIMPLE)
prefs_table.add_column("Setting", style="bold cyan")
prefs_table.add_column("Value")
prefs_table.add_row("Auto Save Findings", "✅ Yes" if config.preferences.auto_save_findings else "❌ No")
prefs_table.add_row("Show Progress Bars", "✅ Yes" if config.preferences.show_progress_bars else "❌ No")
prefs_table.add_row("Table Style", config.preferences.table_style)
prefs_table.add_row("Color Output", "✅ Yes" if config.preferences.color_output else "❌ No")
console.print(
Panel.fit(
prefs_table,
title="🎨 Preferences",
box=box.ROUNDED
)
)
console.print(f"\n📍 Config file: [dim]{config_path}[/dim]")
@app.command("set")
def set_config(
key: str = typer.Argument(..., help="Configuration key to set (e.g., 'project.name', 'project.api_url')"),
value: str = typer.Argument(..., help="Value to set"),
global_config: bool = typer.Option(
False, "--global", "-g",
help="Set in global configuration instead of project config"
)
):
"""
Set a configuration value
"""
if global_config:
config = get_global_config()
config_type = "global"
else:
config = get_project_config()
if not config:
console.print("❌ No project configuration found. Run 'ff init' first.", style="red")
raise typer.Exit(1)
config_type = "project"
# Parse the key path
key_parts = key.split('.')
if len(key_parts) != 2:
console.print("❌ Key must be in format 'section.setting' (e.g., 'project.name')", style="red")
raise typer.Exit(1)
section, setting = key_parts
try:
# Update configuration
if section == "project":
if setting == "name":
config.project.name = value
elif setting == "api_url":
config.project.api_url = value
elif setting == "default_timeout":
config.project.default_timeout = int(value)
elif setting == "default_workflow":
config.project.default_workflow = value if value.lower() != "none" else None
else:
console.print(f"❌ Unknown project setting: {setting}", style="red")
raise typer.Exit(1)
elif section == "retention":
if setting == "max_runs":
config.retention.max_runs = int(value)
elif setting == "keep_findings_days":
config.retention.keep_findings_days = int(value)
else:
console.print(f"❌ Unknown retention setting: {setting}", style="red")
raise typer.Exit(1)
elif section == "preferences":
if setting == "auto_save_findings":
config.preferences.auto_save_findings = value.lower() in ("true", "yes", "1", "on")
elif setting == "show_progress_bars":
config.preferences.show_progress_bars = value.lower() in ("true", "yes", "1", "on")
elif setting == "table_style":
config.preferences.table_style = value
elif setting == "color_output":
config.preferences.color_output = value.lower() in ("true", "yes", "1", "on")
else:
console.print(f"❌ Unknown preferences setting: {setting}", style="red")
raise typer.Exit(1)
else:
console.print(f"❌ Unknown configuration section: {section}", style="red")
console.print("Valid sections: project, retention, preferences", style="dim")
raise typer.Exit(1)
# Save configuration
if global_config:
save_global_config(config)
else:
config_path = Path.cwd() / ".fuzzforge" / "config.yaml"
config.save_to_file(config_path)
console.print(f"✅ Set {config_type} configuration: [bold cyan]{key}[/bold cyan] = [bold]{value}[/bold]", style="green")
except ValueError as e:
console.print(f"❌ Invalid value for {key}: {e}", style="red")
raise typer.Exit(1)
except Exception as e:
console.print(f"❌ Failed to set configuration: {e}", style="red")
raise typer.Exit(1)
@app.command("get")
def get_config(
key: str = typer.Argument(..., help="Configuration key to get (e.g., 'project.name')"),
global_config: bool = typer.Option(
False, "--global", "-g",
help="Get from global configuration instead of project config"
)
):
"""
📖 Get a specific configuration value
"""
if global_config:
config = get_global_config()
else:
config = get_project_config()
if not config:
console.print("❌ No project configuration found. Run 'ff init' first.", style="red")
raise typer.Exit(1)
# Parse the key path
key_parts = key.split('.')
if len(key_parts) != 2:
console.print("❌ Key must be in format 'section.setting' (e.g., 'project.name')", style="red")
raise typer.Exit(1)
section, setting = key_parts
try:
# Get configuration value
if section == "project":
if setting == "name":
value = config.project.name
elif setting == "api_url":
value = config.project.api_url
elif setting == "default_timeout":
value = config.project.default_timeout
elif setting == "default_workflow":
value = config.project.default_workflow or "none"
else:
console.print(f"❌ Unknown project setting: {setting}", style="red")
raise typer.Exit(1)
elif section == "retention":
if setting == "max_runs":
value = config.retention.max_runs
elif setting == "keep_findings_days":
value = config.retention.keep_findings_days
else:
console.print(f"❌ Unknown retention setting: {setting}", style="red")
raise typer.Exit(1)
elif section == "preferences":
if setting == "auto_save_findings":
value = config.preferences.auto_save_findings
elif setting == "show_progress_bars":
value = config.preferences.show_progress_bars
elif setting == "table_style":
value = config.preferences.table_style
elif setting == "color_output":
value = config.preferences.color_output
else:
console.print(f"❌ Unknown preferences setting: {setting}", style="red")
raise typer.Exit(1)
else:
console.print(f"❌ Unknown configuration section: {section}", style="red")
raise typer.Exit(1)
console.print(f"{key}: [bold cyan]{value}[/bold cyan]")
except Exception as e:
console.print(f"❌ Failed to get configuration: {e}", style="red")
raise typer.Exit(1)
@app.command("reset")
def reset_config(
global_config: bool = typer.Option(
False, "--global", "-g",
help="Reset global configuration instead of project config"
),
force: bool = typer.Option(
False, "--force", "-f",
help="Skip confirmation prompt"
)
):
"""
🔄 Reset configuration to defaults
"""
config_type = "global" if global_config else "project"
if not force:
if not Confirm.ask(f"Reset {config_type} configuration to defaults?", default=False, console=console):
console.print("❌ Reset cancelled", style="yellow")
raise typer.Exit(0)
try:
# Create new default configuration
new_config = FuzzForgeConfig()
if global_config:
save_global_config(new_config)
else:
if not Path.cwd().joinpath(".fuzzforge").exists():
console.print("❌ No project configuration found. Run 'ff init' first.", style="red")
raise typer.Exit(1)
config_path = Path.cwd() / ".fuzzforge" / "config.yaml"
new_config.save_to_file(config_path)
console.print(f"{config_type.title()} configuration reset to defaults", style="green")
except Exception as e:
console.print(f"❌ Failed to reset configuration: {e}", style="red")
raise typer.Exit(1)
@app.command("edit")
def edit_config(
global_config: bool = typer.Option(
False, "--global", "-g",
help="Edit global configuration instead of project config"
)
):
"""
📝 Open configuration file in default editor
"""
import os
import subprocess
if global_config:
config_path = Path.home() / ".config" / "fuzzforge" / "config.yaml"
config_type = "global"
else:
config_path = Path.cwd() / ".fuzzforge" / "config.yaml"
config_type = "project"
if not config_path.exists():
console.print("❌ No project configuration found. Run 'ff init' first.", style="red")
raise typer.Exit(1)
# Try to find a suitable editor
editors = ["code", "vim", "nano", "notepad"]
editor = None
for e in editors:
try:
subprocess.run([e, "--version"], capture_output=True, check=True)
editor = e
break
except (subprocess.CalledProcessError, FileNotFoundError):
continue
if not editor:
console.print(f"📍 Configuration file: [bold cyan]{config_path}[/bold cyan]")
console.print("❌ No suitable editor found. Please edit the file manually.", style="red")
raise typer.Exit(1)
try:
console.print(f"📝 Opening {config_type} configuration in {editor}...")
subprocess.run([editor, str(config_path)], check=True)
console.print(f"✅ Configuration file edited", style="green")
except subprocess.CalledProcessError as e:
console.print(f"❌ Failed to open editor: {e}", style="red")
raise typer.Exit(1)
@app.callback()
def config_callback():
"""
Manage configuration settings
"""
pass
+940
View File
@@ -0,0 +1,940 @@
"""
Findings and security results management commands.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import json
import csv
from datetime import datetime
from pathlib import Path
from typing import Optional, Dict, Any, List
import typer
from rich.console import Console
from rich.table import Table, Column
from rich.panel import Panel
from rich.syntax import Syntax
from rich.tree import Tree
from rich.text import Text
from rich import box
from ..config import get_project_config, FuzzForgeConfig
from ..database import get_project_db, ensure_project_db, FindingRecord
from ..exceptions import (
handle_error, retry_on_network_error, validate_run_id,
require_project, ValidationError, DatabaseError
)
from fuzzforge_sdk import FuzzForgeClient
console = Console()
app = typer.Typer()
@retry_on_network_error(max_retries=3, delay=1.0)
def get_client() -> FuzzForgeClient:
"""Get configured FuzzForge client with retry on network errors"""
config = get_project_config() or FuzzForgeConfig()
return FuzzForgeClient(base_url=config.get_api_url(), timeout=config.get_timeout())
def severity_style(severity: str) -> str:
"""Get rich style for severity level"""
return {
"error": "bold red",
"warning": "bold yellow",
"note": "bold blue",
"info": "bold cyan"
}.get(severity.lower(), "white")
@app.command("get")
def get_findings(
run_id: str = typer.Argument(..., help="Run ID to get findings for"),
save: bool = typer.Option(
True, "--save/--no-save",
help="Save findings to local database"
),
format: str = typer.Option(
"table", "--format", "-f",
help="Output format: table, json, sarif"
)
):
"""
🔍 Retrieve and display security findings for a run
"""
try:
require_project()
validate_run_id(run_id)
if format not in ["table", "json", "sarif"]:
raise ValidationError("format", format, "one of: table, json, sarif")
with get_client() as client:
console.print(f"🔍 Fetching findings for run: {run_id}")
findings = client.get_run_findings(run_id)
# Save to database if requested
if save:
try:
db = ensure_project_db()
# Extract summary from SARIF
sarif_data = findings.sarif
runs_data = sarif_data.get("runs", [])
summary = {}
if runs_data:
results = runs_data[0].get("results", [])
summary = {
"total_issues": len(results),
"by_severity": {},
"by_rule": {},
"tools": []
}
for result in results:
level = result.get("level", "note")
rule_id = result.get("ruleId", "unknown")
summary["by_severity"][level] = summary["by_severity"].get(level, 0) + 1
summary["by_rule"][rule_id] = summary["by_rule"].get(rule_id, 0) + 1
# Extract tool info
tool = runs_data[0].get("tool", {})
driver = tool.get("driver", {})
if driver.get("name"):
summary["tools"].append({
"name": driver.get("name"),
"version": driver.get("version"),
"rules": len(driver.get("rules", []))
})
finding_record = FindingRecord(
run_id=run_id,
sarif_data=sarif_data,
summary=summary,
created_at=datetime.now()
)
db.save_findings(finding_record)
console.print("✅ Findings saved to local database", style="green")
except Exception as e:
console.print(f"⚠️ Failed to save findings to database: {e}", style="yellow")
# Display findings
if format == "json":
findings_json = json.dumps(findings.sarif, indent=2)
console.print(Syntax(findings_json, "json", theme="monokai"))
elif format == "sarif":
sarif_json = json.dumps(findings.sarif, indent=2)
console.print(sarif_json)
else: # table format
display_findings_table(findings.sarif)
except Exception as e:
console.print(f"❌ Failed to get findings: {e}", style="red")
raise typer.Exit(1)
def display_findings_table(sarif_data: Dict[str, Any]):
"""Display SARIF findings in a rich table format"""
runs = sarif_data.get("runs", [])
if not runs:
console.print("️ No findings data available", style="dim")
return
run_data = runs[0]
results = run_data.get("results", [])
tool = run_data.get("tool", {})
driver = tool.get("driver", {})
# Tool information
console.print(f"\n🔍 [bold]Security Analysis Results[/bold]")
if driver.get("name"):
console.print(f"Tool: {driver.get('name')} v{driver.get('version', 'unknown')}")
if not results:
console.print("✅ No security issues found!", style="green")
return
# Summary statistics
summary_by_level = {}
for result in results:
level = result.get("level", "note")
summary_by_level[level] = summary_by_level.get(level, 0) + 1
summary_table = Table(show_header=False, box=box.SIMPLE)
summary_table.add_column("Severity", width=15, justify="left", style="bold")
summary_table.add_column("Count", width=8, justify="right", style="bold")
for level, count in sorted(summary_by_level.items()):
# Create Rich Text object with color styling
level_text = level.upper()
severity_text = Text(level_text, style=severity_style(level))
count_text = Text(str(count))
summary_table.add_row(severity_text, count_text)
console.print(
Panel.fit(
summary_table,
title=f"📊 Summary ({len(results)} total issues)",
box=box.ROUNDED
)
)
# Detailed results - Rich Text-based table with proper emoji alignment
results_table = Table(box=box.ROUNDED)
results_table.add_column("Severity", width=12, justify="left", no_wrap=True)
results_table.add_column("Rule", width=25, justify="left", style="bold cyan", no_wrap=True)
results_table.add_column("Message", width=55, justify="left", no_wrap=True)
results_table.add_column("Location", width=20, justify="left", style="dim", no_wrap=True)
for result in results[:50]: # Limit to first 50 results
level = result.get("level", "note")
rule_id = result.get("ruleId", "unknown")
message = result.get("message", {}).get("text", "No message")
# Extract location information
locations = result.get("locations", [])
location_str = ""
if locations:
physical_location = locations[0].get("physicalLocation", {})
artifact_location = physical_location.get("artifactLocation", {})
region = physical_location.get("region", {})
file_path = artifact_location.get("uri", "")
if file_path:
location_str = Path(file_path).name
if region.get("startLine"):
location_str += f":{region['startLine']}"
if region.get("startColumn"):
location_str += f":{region['startColumn']}"
# Create Rich Text objects with color styling
severity_text = Text(level.upper(), style=severity_style(level))
severity_text.truncate(12, overflow="ellipsis")
rule_text = Text(rule_id)
rule_text.truncate(25, overflow="ellipsis")
message_text = Text(message)
message_text.truncate(55, overflow="ellipsis")
location_text = Text(location_str)
location_text.truncate(20, overflow="ellipsis")
results_table.add_row(
severity_text,
rule_text,
message_text,
location_text
)
console.print(f"\n📋 [bold]Detailed Results[/bold]")
if len(results) > 50:
console.print(f"Showing first 50 of {len(results)} results")
console.print()
console.print(results_table)
@app.command("history")
def findings_history(
limit: int = typer.Option(20, "--limit", "-l", help="Maximum number of findings to show")
):
"""
📚 Show findings history from local database
"""
db = get_project_db()
if not db:
console.print("❌ No FuzzForge project found. Run 'ff init' first.", style="red")
raise typer.Exit(1)
try:
findings = db.list_findings(limit=limit)
if not findings:
console.print("❌ No findings found in database", style="red")
return
table = Table(box=box.ROUNDED)
table.add_column("Run ID", style="bold cyan", width=36) # Full UUID width
table.add_column("Date", justify="center")
table.add_column("Total Issues", justify="center", style="bold")
table.add_column("Errors", justify="center", style="red")
table.add_column("Warnings", justify="center", style="yellow")
table.add_column("Notes", justify="center", style="blue")
table.add_column("Tools", style="dim")
for finding in findings:
summary = finding.summary
total_issues = summary.get("total_issues", 0)
by_severity = summary.get("by_severity", {})
tools = summary.get("tools", [])
tool_names = ", ".join([tool.get("name", "Unknown") for tool in tools])
table.add_row(
finding.run_id, # Show full Run ID
finding.created_at.strftime("%m-%d %H:%M"),
str(total_issues),
str(by_severity.get("error", 0)),
str(by_severity.get("warning", 0)),
str(by_severity.get("note", 0)),
tool_names[:30] + "..." if len(tool_names) > 30 else tool_names
)
console.print(f"\n📚 [bold]Findings History ({len(findings)})[/bold]\n")
console.print(table)
console.print(f"\n💡 Use [bold cyan]fuzzforge finding <run-id>[/bold cyan] to view detailed findings")
except Exception as e:
console.print(f"❌ Failed to get findings history: {e}", style="red")
raise typer.Exit(1)
@app.command("export")
def export_findings(
run_id: str = typer.Argument(..., help="Run ID to export findings for"),
format: str = typer.Option(
"json", "--format", "-f",
help="Export format: json, csv, html, sarif"
),
output: Optional[str] = typer.Option(
None, "--output", "-o",
help="Output file path (defaults to findings-<run-id>.<format>)"
)
):
"""
📤 Export security findings in various formats
"""
db = get_project_db()
if not db:
console.print("❌ No FuzzForge project found. Run 'ff init' first.", style="red")
raise typer.Exit(1)
try:
# Get findings from database first, fallback to API
findings_data = db.get_findings(run_id)
if not findings_data:
console.print(f"📡 Fetching findings from API for run: {run_id}")
with get_client() as client:
findings = client.get_run_findings(run_id)
sarif_data = findings.sarif
else:
sarif_data = findings_data.sarif_data
# Generate output filename
if not output:
output = f"findings-{run_id[:8]}.{format}"
output_path = Path(output)
# Export based on format
if format == "sarif":
with open(output_path, 'w') as f:
json.dump(sarif_data, f, indent=2)
elif format == "json":
# Simplified JSON format
simplified_data = extract_simplified_findings(sarif_data)
with open(output_path, 'w') as f:
json.dump(simplified_data, f, indent=2)
elif format == "csv":
export_to_csv(sarif_data, output_path)
elif format == "html":
export_to_html(sarif_data, output_path, run_id)
else:
console.print(f"❌ Unsupported format: {format}", style="red")
raise typer.Exit(1)
console.print(f"✅ Findings exported to: [bold cyan]{output_path}[/bold cyan]")
except Exception as e:
console.print(f"❌ Failed to export findings: {e}", style="red")
raise typer.Exit(1)
def extract_simplified_findings(sarif_data: Dict[str, Any]) -> Dict[str, Any]:
"""Extract simplified findings structure from SARIF"""
runs = sarif_data.get("runs", [])
if not runs:
return {"findings": [], "summary": {}}
run_data = runs[0]
results = run_data.get("results", [])
tool = run_data.get("tool", {}).get("driver", {})
simplified = {
"tool": {
"name": tool.get("name", "Unknown"),
"version": tool.get("version", "Unknown")
},
"summary": {
"total_issues": len(results),
"by_severity": {}
},
"findings": []
}
for result in results:
level = result.get("level", "note")
simplified["summary"]["by_severity"][level] = simplified["summary"]["by_severity"].get(level, 0) + 1
# Extract location
location_info = {}
locations = result.get("locations", [])
if locations:
physical_location = locations[0].get("physicalLocation", {})
artifact_location = physical_location.get("artifactLocation", {})
region = physical_location.get("region", {})
location_info = {
"file": artifact_location.get("uri", ""),
"line": region.get("startLine"),
"column": region.get("startColumn")
}
simplified["findings"].append({
"rule_id": result.get("ruleId", "unknown"),
"severity": level,
"message": result.get("message", {}).get("text", ""),
"location": location_info
})
return simplified
def export_to_csv(sarif_data: Dict[str, Any], output_path: Path):
"""Export findings to CSV format"""
runs = sarif_data.get("runs", [])
if not runs:
return
results = runs[0].get("results", [])
with open(output_path, 'w', newline='', encoding='utf-8') as csvfile:
fieldnames = ['rule_id', 'severity', 'message', 'file', 'line', 'column']
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writeheader()
for result in results:
location_info = {"file": "", "line": "", "column": ""}
locations = result.get("locations", [])
if locations:
physical_location = locations[0].get("physicalLocation", {})
artifact_location = physical_location.get("artifactLocation", {})
region = physical_location.get("region", {})
location_info = {
"file": artifact_location.get("uri", ""),
"line": region.get("startLine", ""),
"column": region.get("startColumn", "")
}
writer.writerow({
"rule_id": result.get("ruleId", ""),
"severity": result.get("level", "note"),
"message": result.get("message", {}).get("text", ""),
**location_info
})
def export_to_html(sarif_data: Dict[str, Any], output_path: Path, run_id: str):
"""Export findings to HTML format"""
runs = sarif_data.get("runs", [])
if not runs:
return
run_data = runs[0]
results = run_data.get("results", [])
tool = run_data.get("tool", {}).get("driver", {})
# Simple HTML template
html_content = f"""<!DOCTYPE html>
<html>
<head>
<title>Security Findings - {run_id}</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 40px; }}
.header {{ background: #f4f4f4; padding: 20px; border-radius: 5px; }}
.summary {{ margin: 20px 0; }}
.findings {{ margin: 20px 0; }}
table {{ width: 100%; border-collapse: collapse; }}
th, td {{ padding: 10px; text-align: left; border-bottom: 1px solid #ddd; }}
th {{ background-color: #f2f2f2; }}
.error {{ color: #d32f2f; }}
.warning {{ color: #f57c00; }}
.note {{ color: #1976d2; }}
.info {{ color: #388e3c; }}
</style>
</head>
<body>
<div class="header">
<h1>Security Findings Report</h1>
<p><strong>Run ID:</strong> {run_id}</p>
<p><strong>Tool:</strong> {tool.get('name', 'Unknown')} v{tool.get('version', 'Unknown')}</p>
<p><strong>Generated:</strong> {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
</div>
<div class="summary">
<h2>Summary</h2>
<p><strong>Total Issues:</strong> {len(results)}</p>
</div>
<div class="findings">
<h2>Detailed Findings</h2>
<table>
<thead>
<tr>
<th>Rule ID</th>
<th>Severity</th>
<th>Message</th>
<th>Location</th>
</tr>
</thead>
<tbody>
"""
for result in results:
level = result.get("level", "note")
rule_id = result.get("ruleId", "unknown")
message = result.get("message", {}).get("text", "")
# Extract location
location_str = ""
locations = result.get("locations", [])
if locations:
physical_location = locations[0].get("physicalLocation", {})
artifact_location = physical_location.get("artifactLocation", {})
region = physical_location.get("region", {})
file_path = artifact_location.get("uri", "")
if file_path:
location_str = file_path
if region.get("startLine"):
location_str += f":{region['startLine']}"
html_content += f"""
<tr>
<td>{rule_id}</td>
<td class="{level}">{level}</td>
<td>{message}</td>
<td>{location_str}</td>
</tr>
"""
html_content += """
</tbody>
</table>
</div>
</body>
</html>
"""
with open(output_path, 'w', encoding='utf-8') as f:
f.write(html_content)
@app.command("all")
def all_findings(
workflow: Optional[str] = typer.Option(
None, "--workflow", "-w",
help="Filter by workflow name"
),
severity: Optional[str] = typer.Option(
None, "--severity", "-s",
help="Filter by severity levels (comma-separated: error,warning,note,info)"
),
since: Optional[str] = typer.Option(
None, "--since",
help="Show findings since date (YYYY-MM-DD)"
),
limit: Optional[int] = typer.Option(
None, "--limit", "-l",
help="Maximum number of findings to show"
),
export_format: Optional[str] = typer.Option(
None, "--export", "-e",
help="Export format: json, csv, html"
),
output: Optional[str] = typer.Option(
None, "--output", "-o",
help="Output file for export"
),
stats_only: bool = typer.Option(
False, "--stats",
help="Show statistics only"
),
show_findings: bool = typer.Option(
False, "--show-findings", "-f",
help="Show actual findings content, not just summary"
),
max_findings: int = typer.Option(
50, "--max-findings",
help="Maximum number of individual findings to display"
)
):
"""
📊 Show all findings for the entire project
"""
db = get_project_db()
if not db:
console.print("❌ No FuzzForge project found. Run 'ff init' first.", style="red")
raise typer.Exit(1)
try:
# Parse filters
severity_list = None
if severity:
severity_list = [s.strip().lower() for s in severity.split(",")]
since_date = None
if since:
try:
since_date = datetime.strptime(since, "%Y-%m-%d")
except ValueError:
console.print(f"❌ Invalid date format: {since}. Use YYYY-MM-DD", style="red")
raise typer.Exit(1)
# Get aggregated stats
stats = db.get_aggregated_stats()
# Show statistics
if stats_only or not export_format:
# Create summary panel
summary_text = f"""[bold]📊 Project Security Summary[/bold]
[cyan]Total Findings Records:[/cyan] {stats['total_findings_records']}
[cyan]Total Runs Analyzed:[/cyan] {stats['total_runs']}
[cyan]Total Security Issues:[/cyan] {stats['total_issues']}
[cyan]Recent Findings (7 days):[/cyan] {stats['recent_findings']}
[bold]Severity Distribution:[/bold]
🔴 Errors: {stats['severity_distribution'].get('error', 0)}
🟡 Warnings: {stats['severity_distribution'].get('warning', 0)}
🔵 Notes: {stats['severity_distribution'].get('note', 0)}
Info: {stats['severity_distribution'].get('info', 0)}
[bold]By Workflow:[/bold]"""
for wf_name, count in stats['workflows'].items():
summary_text += f"\n{wf_name}: {count} findings"
console.print(Panel(summary_text, box=box.ROUNDED, title="FuzzForge Project Analysis", border_style="cyan"))
if stats_only:
return
# Get all findings with filters
findings = db.get_all_findings(
workflow=workflow,
severity=severity_list,
since_date=since_date,
limit=limit
)
if not findings:
console.print("️ No findings match the specified filters", style="dim")
return
# Export if requested
if export_format:
if not output:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output = f"all_findings_{timestamp}.{export_format}"
export_all_findings(findings, export_format, output)
console.print(f"✅ Exported {len(findings)} findings to: {output}", style="green")
return
# Display findings table
table = Table(box=box.ROUNDED, title=f"All Project Findings ({len(findings)} records)")
table.add_column("Run ID", style="bold cyan", width=36) # Full UUID width
table.add_column("Workflow", style="dim", width=20)
table.add_column("Date", justify="center")
table.add_column("Issues", justify="center", style="bold")
table.add_column("Errors", justify="center", style="red")
table.add_column("Warnings", justify="center", style="yellow")
table.add_column("Notes", justify="center", style="blue")
# Get run info for each finding
runs_info = {}
for finding in findings:
run_id = finding.run_id
if run_id not in runs_info:
run_info = db.get_run(run_id)
runs_info[run_id] = run_info
for finding in findings:
run_id = finding.run_id
run_info = runs_info.get(run_id)
workflow_name = run_info.workflow if run_info else "unknown"
summary = finding.summary
total_issues = summary.get("total_issues", 0)
by_severity = summary.get("by_severity", {})
# Count issues from SARIF data if summary is incomplete
if total_issues == 0 and "runs" in finding.sarif_data:
for run in finding.sarif_data["runs"]:
total_issues += len(run.get("results", []))
table.add_row(
run_id, # Show full Run ID
workflow_name[:17] + "..." if len(workflow_name) > 20 else workflow_name,
finding.created_at.strftime("%Y-%m-%d %H:%M"),
str(total_issues),
str(by_severity.get("error", 0)),
str(by_severity.get("warning", 0)),
str(by_severity.get("note", 0))
)
console.print(table)
# Show actual findings if requested
if show_findings:
display_detailed_findings(findings, max_findings)
console.print(f"\n💡 Use filters to refine results: --workflow, --severity, --since")
console.print(f"💡 Show findings content: --show-findings")
console.print(f"💡 Export findings: --export json --output report.json")
console.print(f"💡 View specific findings: [bold cyan]fuzzforge finding <run-id>[/bold cyan]")
except Exception as e:
console.print(f"❌ Failed to get all findings: {e}", style="red")
raise typer.Exit(1)
def display_detailed_findings(findings: List[FindingRecord], max_findings: int):
"""Display detailed findings content"""
console.print(f"\n📋 [bold]Detailed Findings Content[/bold] (showing up to {max_findings} findings)\n")
findings_count = 0
for finding_record in findings:
if findings_count >= max_findings:
remaining = sum(len(run.get("results", []))
for f in findings[findings.index(finding_record):]
for run in f.sarif_data.get("runs", []))
if remaining > 0:
console.print(f"\n... and {remaining} more findings (use --max-findings to show more)")
break
# Get run info for this finding
sarif_data = finding_record.sarif_data
if not sarif_data or "runs" not in sarif_data:
continue
for run in sarif_data["runs"]:
tool = run.get("tool", {})
driver = tool.get("driver", {})
tool_name = driver.get("name", "Unknown Tool")
results = run.get("results", [])
if not results:
continue
# Group results by severity
for result in results:
if findings_count >= max_findings:
break
findings_count += 1
# Extract key information
rule_id = result.get("ruleId", "unknown")
level = result.get("level", "note").upper()
message_text = result.get("message", {}).get("text", "No description")
# Get location information
locations = result.get("locations", [])
location_str = "Unknown location"
if locations:
physical = locations[0].get("physicalLocation", {})
artifact = physical.get("artifactLocation", {})
region = physical.get("region", {})
file_path = artifact.get("uri", "")
line_number = region.get("startLine", "")
if file_path:
location_str = f"{file_path}"
if line_number:
location_str += f":{line_number}"
# Get severity style
severity_style = {
"ERROR": "bold red",
"WARNING": "bold yellow",
"NOTE": "bold blue",
"INFO": "bold cyan"
}.get(level, "white")
# Create finding panel
finding_content = f"""[bold]Rule:[/bold] {rule_id}
[bold]Location:[/bold] {location_str}
[bold]Tool:[/bold] {tool_name}
[bold]Run:[/bold] {finding_record.run_id[:12]}...
[bold]Description:[/bold]
{message_text}"""
# Add code context if available
region = locations[0].get("physicalLocation", {}).get("region", {}) if locations else {}
if region.get("snippet", {}).get("text"):
code_snippet = region["snippet"]["text"].strip()
finding_content += f"\n\n[bold]Code:[/bold]\n[dim]{code_snippet}[/dim]"
console.print(Panel(
finding_content,
title=f"[{severity_style}]{level}[/{severity_style}] Finding #{findings_count}",
border_style=severity_style.split()[-1] if " " in severity_style else severity_style,
box=box.ROUNDED
))
console.print() # Add spacing between findings
def export_all_findings(findings: List[FindingRecord], format: str, output_path: str):
"""Export all findings to specified format"""
output_file = Path(output_path)
if format == "json":
# Combine all SARIF data
all_results = []
for finding in findings:
if "runs" in finding.sarif_data:
for run in finding.sarif_data["runs"]:
for result in run.get("results", []):
result_entry = {
"run_id": finding.run_id,
"created_at": finding.created_at.isoformat(),
**result
}
all_results.append(result_entry)
with open(output_file, 'w') as f:
json.dump({
"total_findings": len(findings),
"export_date": datetime.now().isoformat(),
"results": all_results
}, f, indent=2)
elif format == "csv":
# Export to CSV
with open(output_file, 'w', newline='') as f:
writer = csv.writer(f)
writer.writerow(["Run ID", "Date", "Severity", "Rule ID", "Message", "File", "Line"])
for finding in findings:
if "runs" in finding.sarif_data:
for run in finding.sarif_data["runs"]:
for result in run.get("results", []):
locations = result.get("locations", [])
location_info = locations[0] if locations else {}
physical = location_info.get("physicalLocation", {})
artifact = physical.get("artifactLocation", {})
region = physical.get("region", {})
writer.writerow([
finding.run_id[:12],
finding.created_at.strftime("%Y-%m-%d %H:%M"),
result.get("level", "note"),
result.get("ruleId", ""),
result.get("message", {}).get("text", ""),
artifact.get("uri", ""),
region.get("startLine", "")
])
elif format == "html":
# Generate HTML report
html_content = f"""<!DOCTYPE html>
<html>
<head>
<title>FuzzForge Security Findings Report</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 20px; }}
h1 {{ color: #333; }}
.stats {{ background: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0; }}
table {{ width: 100%; border-collapse: collapse; }}
th, td {{ padding: 10px; text-align: left; border-bottom: 1px solid #ddd; }}
th {{ background: #4CAF50; color: white; }}
.error {{ color: red; font-weight: bold; }}
.warning {{ color: orange; font-weight: bold; }}
.note {{ color: blue; }}
.info {{ color: gray; }}
</style>
</head>
<body>
<h1>FuzzForge Security Findings Report</h1>
<div class="stats">
<p><strong>Generated:</strong> {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</p>
<p><strong>Total Findings:</strong> {len(findings)}</p>
</div>
<table>
<tr>
<th>Run ID</th>
<th>Date</th>
<th>Severity</th>
<th>Rule</th>
<th>Message</th>
<th>Location</th>
</tr>"""
for finding in findings:
if "runs" in finding.sarif_data:
for run in finding.sarif_data["runs"]:
for result in run.get("results", []):
level = result.get("level", "note")
locations = result.get("locations", [])
location_info = locations[0] if locations else {}
physical = location_info.get("physicalLocation", {})
artifact = physical.get("artifactLocation", {})
region = physical.get("region", {})
html_content += f"""
<tr>
<td>{finding.run_id[:12]}</td>
<td>{finding.created_at.strftime("%Y-%m-%d %H:%M")}</td>
<td class="{level}">{level.upper()}</td>
<td>{result.get("ruleId", "")}</td>
<td>{result.get("message", {}).get("text", "")}</td>
<td>{artifact.get("uri", "")} : {region.get("startLine", "")}</td>
</tr>"""
html_content += """
</table>
</body>
</html>"""
with open(output_file, 'w') as f:
f.write(html_content)
@app.callback(invoke_without_command=True)
def findings_callback(ctx: typer.Context):
"""
🔍 View and export security findings
"""
# Check if a subcommand is being invoked
if ctx.invoked_subcommand is not None:
# Let the subcommand handle it
return
# Default to history when no subcommand provided
findings_history(limit=20)
+251
View File
@@ -0,0 +1,251 @@
"""Cognee ingestion commands for FuzzForge CLI."""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
from __future__ import annotations
import asyncio
import os
from pathlib import Path
from typing import List, Optional
import typer
from rich.console import Console
from rich.prompt import Confirm
from ..config import ProjectConfigManager
from ..ingest_utils import collect_ingest_files
console = Console()
app = typer.Typer(
name="ingest",
help="Ingest files or directories into the Cognee knowledge graph for the current project",
invoke_without_command=True,
)
@app.callback()
def ingest_callback(
ctx: typer.Context,
path: Optional[Path] = typer.Argument(
None,
exists=True,
file_okay=True,
dir_okay=True,
readable=True,
resolve_path=True,
help="File or directory to ingest (defaults to current directory)",
),
recursive: bool = typer.Option(
False,
"--recursive",
"-r",
help="Recursively ingest directories",
),
file_types: Optional[List[str]] = typer.Option(
None,
"--file-types",
"-t",
help="File extensions to include (e.g. --file-types .py --file-types .js)",
),
exclude: Optional[List[str]] = typer.Option(
None,
"--exclude",
"-e",
help="Glob patterns to exclude",
),
dataset: Optional[str] = typer.Option(
None,
"--dataset",
"-d",
help="Dataset name to ingest into",
),
force: bool = typer.Option(
False,
"--force",
"-f",
help="Force re-ingestion and skip confirmation",
),
):
"""Entry point for `fuzzforge ingest` when no subcommand is provided."""
if ctx.invoked_subcommand:
return
try:
config = ProjectConfigManager()
except FileNotFoundError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1) from exc
if not config.is_initialized():
console.print("[red]Error: FuzzForge project not initialized. Run 'ff init' first.[/red]")
raise typer.Exit(1)
config.setup_cognee_environment()
if os.getenv("FUZZFORGE_DEBUG", "0") == "1":
console.print(
"[dim]Cognee directories:\n"
f" DATA: {os.getenv('COGNEE_DATA_ROOT', 'unset')}\n"
f" SYSTEM: {os.getenv('COGNEE_SYSTEM_ROOT', 'unset')}\n"
f" USER: {os.getenv('COGNEE_USER_ID', 'unset')}\n",
)
project_context = config.get_project_context()
target_path = path or Path.cwd()
dataset_name = dataset or f"{project_context['project_name']}_codebase"
try:
import cognee # noqa: F401 # Just to validate installation
except ImportError as exc:
console.print("[red]Cognee is not installed.[/red]")
console.print("Install with: pip install 'cognee[all]' litellm")
raise typer.Exit(1) from exc
console.print(f"[bold]🔍 Ingesting {target_path} into Cognee knowledge graph[/bold]")
console.print(
f"Project: [cyan]{project_context['project_name']}[/cyan] "
f"(ID: [dim]{project_context['project_id']}[/dim])"
)
console.print(f"Dataset: [cyan]{dataset_name}[/cyan]")
console.print(f"Tenant: [dim]{project_context['tenant_id']}[/dim]")
if not force:
confirm_message = f"Ingest {target_path} into knowledge graph for this project?"
if not Confirm.ask(confirm_message, console=console):
console.print("[yellow]Ingestion cancelled[/yellow]")
raise typer.Exit(0)
try:
asyncio.run(
_run_ingestion(
config=config,
path=target_path.resolve(),
recursive=recursive,
file_types=file_types,
exclude=exclude,
dataset=dataset_name,
force=force,
)
)
except KeyboardInterrupt:
console.print("\n[yellow]Ingestion cancelled by user[/yellow]")
raise typer.Exit(1)
except Exception as exc: # pragma: no cover - rich reporting
console.print(f"[red]Failed to ingest:[/red] {exc}")
raise typer.Exit(1) from exc
async def _run_ingestion(
*,
config: ProjectConfigManager,
path: Path,
recursive: bool,
file_types: Optional[List[str]],
exclude: Optional[List[str]],
dataset: str,
force: bool,
) -> None:
"""Perform the actual ingestion work."""
from fuzzforge_ai.cognee_service import CogneeService
cognee_service = CogneeService(config)
await cognee_service.initialize()
# Always skip internal bookkeeping directories
exclude_patterns = list(exclude or [])
default_excludes = {
".fuzzforge/**",
".git/**",
}
added_defaults = []
for pattern in default_excludes:
if pattern not in exclude_patterns:
exclude_patterns.append(pattern)
added_defaults.append(pattern)
if added_defaults and os.getenv("FUZZFORGE_DEBUG", "0") == "1":
console.print(
"[dim]Auto-excluding paths: {patterns}[/dim]".format(
patterns=", ".join(added_defaults)
)
)
try:
files_to_ingest = collect_ingest_files(path, recursive, file_types, exclude_patterns)
except Exception as exc:
console.print(f"[red]Failed to collect files:[/red] {exc}")
return
if not files_to_ingest:
console.print("[yellow]No files found to ingest[/yellow]")
return
console.print(f"Found [green]{len(files_to_ingest)}[/green] files to ingest")
if force:
console.print("Cleaning existing data for this project...")
try:
await cognee_service.clear_data(confirm=True)
except Exception as exc:
console.print(f"[yellow]Warning:[/yellow] Could not clean existing data: {exc}")
console.print("Adding files to Cognee...")
valid_file_paths = []
for file_path in files_to_ingest:
try:
with open(file_path, "r", encoding="utf-8") as fh:
fh.read(1)
valid_file_paths.append(file_path)
console.print(f"{file_path}")
except (UnicodeDecodeError, PermissionError) as exc:
console.print(f"[yellow]Skipping {file_path}: {exc}[/yellow]")
if not valid_file_paths:
console.print("[yellow]No readable files found to ingest[/yellow]")
return
results = await cognee_service.ingest_files(valid_file_paths, dataset)
console.print(
f"[green]✅ Successfully ingested {results['success']} files into knowledge graph[/green]"
)
if results["failed"]:
console.print(
f"[yellow]⚠️ Skipped {results['failed']} files due to errors[/yellow]"
)
try:
insights = await cognee_service.search_insights(
query=f"What insights can you provide about the {dataset} dataset?",
dataset=dataset,
)
if insights:
console.print(f"\n[bold]📊 Generated {len(insights)} insights:[/bold]")
for index, insight in enumerate(insights[:3], 1):
console.print(f" {index}. {insight}")
if len(insights) > 3:
console.print(f" ... and {len(insights) - 3} more")
chunks = await cognee_service.search_chunks(
query=f"functions classes methods in {dataset}",
dataset=dataset,
)
if chunks:
console.print(
f"\n[bold]🔍 Sample searchable content ({len(chunks)} chunks found):[/bold]"
)
for index, chunk in enumerate(chunks[:2], 1):
preview = chunk[:100] + "..." if len(chunk) > 100 else chunk
console.print(f" {index}. {preview}")
except Exception:
# Best-effort stats — ignore failures here
pass
+282
View File
@@ -0,0 +1,282 @@
"""Project initialization commands."""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
from __future__ import annotations
from pathlib import Path
import os
from textwrap import dedent
from typing import Optional
import typer
from rich.console import Console
from rich.prompt import Confirm, Prompt
from ..config import ensure_project_config
from ..database import ensure_project_db
console = Console()
app = typer.Typer()
@app.command()
def project(
name: Optional[str] = typer.Option(
None, "--name", "-n",
help="Project name (defaults to current directory name)"
),
api_url: Optional[str] = typer.Option(
None, "--api-url", "-u",
help="FuzzForge API URL (defaults to http://localhost:8000)"
),
force: bool = typer.Option(
False, "--force", "-f",
help="Force initialization even if project already exists"
)
):
"""
📁 Initialize a new FuzzForge project in the current directory.
This creates a .fuzzforge directory with:
SQLite database for storing runs, findings, and crashes
Configuration file with project settings
Default ignore patterns and preferences
"""
current_dir = Path.cwd()
fuzzforge_dir = current_dir / ".fuzzforge"
# Check if project already exists
if fuzzforge_dir.exists() and not force:
if fuzzforge_dir.is_dir() and any(fuzzforge_dir.iterdir()):
console.print("❌ FuzzForge project already exists in this directory", style="red")
console.print("Use --force to reinitialize", style="dim")
raise typer.Exit(1)
# Get project name
if not name:
name = Prompt.ask(
"Project name",
default=current_dir.name,
console=console
)
# Get API URL
if not api_url:
api_url = Prompt.ask(
"FuzzForge API URL",
default="http://localhost:8000",
console=console
)
# Confirm initialization
console.print(f"\n📁 Initializing FuzzForge project: [bold cyan]{name}[/bold cyan]")
console.print(f"📍 Location: [dim]{current_dir}[/dim]")
console.print(f"🔗 API URL: [dim]{api_url}[/dim]")
if not Confirm.ask("\nProceed with initialization?", default=True, console=console):
console.print("❌ Initialization cancelled", style="yellow")
raise typer.Exit(0)
try:
# Create .fuzzforge directory
console.print("\n🔨 Creating project structure...")
fuzzforge_dir.mkdir(exist_ok=True)
# Initialize configuration
console.print("⚙️ Setting up configuration...")
ensure_project_config(
project_dir=current_dir,
project_name=name,
api_url=api_url,
)
# Initialize database
console.print("🗄️ Initializing database...")
ensure_project_db(current_dir)
_ensure_env_file(fuzzforge_dir, force)
_ensure_agents_registry(fuzzforge_dir, force)
# Create .gitignore if needed
gitignore_path = current_dir / ".gitignore"
gitignore_entries = [
"# FuzzForge CLI",
".fuzzforge/findings.db-*", # SQLite temp files
".fuzzforge/cache/",
".fuzzforge/temp/",
]
if gitignore_path.exists():
with open(gitignore_path, 'r') as f:
existing_content = f.read()
if "# FuzzForge CLI" not in existing_content:
with open(gitignore_path, 'a') as f:
f.write(f"\n{chr(10).join(gitignore_entries)}\n")
console.print("📝 Updated .gitignore with FuzzForge entries")
else:
with open(gitignore_path, 'w') as f:
f.write(f"{chr(10).join(gitignore_entries)}\n")
console.print("📝 Created .gitignore")
# Create README if it doesn't exist
readme_path = current_dir / "README.md"
if not readme_path.exists():
readme_content = f"""# {name}
FuzzForge security testing project.
## Quick Start
```bash
# List available workflows
fuzzforge workflows
# Submit a workflow for analysis
fuzzforge workflow <workflow-name> /path/to/target
# Monitor run progress
fuzzforge monitor live <run-id>
# View findings
fuzzforge finding <run-id>
```
## Project Structure
- `.fuzzforge/` - Project data and configuration
- `.fuzzforge/config.yaml` - Project configuration
- `.fuzzforge/findings.db` - Local database for runs and findings
"""
with open(readme_path, 'w') as f:
f.write(readme_content)
console.print("📚 Created README.md")
console.print("\n✅ FuzzForge project initialized successfully!", style="green")
console.print(f"\n🎯 Next steps:")
console.print(" • ff workflows - See available workflows")
console.print(" • ff status - Check API connectivity")
console.print(" • ff workflow <workflow> <path> - Start your first analysis")
console.print(" • edit .fuzzforge/.env with API keys & provider settings")
except Exception as e:
console.print(f"\n❌ Initialization failed: {e}", style="red")
raise typer.Exit(1)
@app.callback()
def init_callback():
"""
📁 Initialize FuzzForge projects and components
"""
def _ensure_env_file(fuzzforge_dir: Path, force: bool) -> None:
"""Create or update the .fuzzforge/.env file with AI defaults."""
env_path = fuzzforge_dir / ".env"
if env_path.exists() and not force:
console.print("🧪 Using existing .fuzzforge/.env (use --force to regenerate)")
return
console.print("🧠 Configuring AI environment...")
console.print(" • Default LLM provider: openai")
console.print(" • Default LLM model: gpt-5-mini")
console.print(" • To customise provider/model later, edit .fuzzforge/.env")
llm_provider = "openai"
llm_model = "gpt-5-mini"
api_key = Prompt.ask(
"OpenAI API key (leave blank to fill manually)",
default="",
show_default=False,
console=console,
)
enable_cognee = False
cognee_url = ""
session_db_path = fuzzforge_dir / "fuzzforge_sessions.db"
session_db_rel = session_db_path.relative_to(fuzzforge_dir.parent)
env_lines = [
"# FuzzForge AI configuration",
"# Populate the API key(s) that match your LLM provider",
"",
f"LLM_PROVIDER={llm_provider}",
f"LLM_MODEL={llm_model}",
f"LITELLM_MODEL={llm_model}",
f"OPENAI_API_KEY={api_key}",
f"FUZZFORGE_MCP_URL={os.getenv('FUZZFORGE_MCP_URL', 'http://localhost:8010/mcp')}",
"",
"# Cognee configuration mirrors the primary LLM by default",
f"LLM_COGNEE_PROVIDER={llm_provider}",
f"LLM_COGNEE_MODEL={llm_model}",
f"LLM_COGNEE_API_KEY={api_key}",
"LLM_COGNEE_ENDPOINT=",
"COGNEE_MCP_URL=",
"",
"# Session persistence options: inmemory | sqlite",
"SESSION_PERSISTENCE=sqlite",
f"SESSION_DB_PATH={session_db_rel}",
"",
"# Optional integrations",
"AGENTOPS_API_KEY=",
"FUZZFORGE_DEBUG=0",
"",
]
env_path.write_text("\n".join(env_lines), encoding="utf-8")
console.print(f"📝 Created {env_path.relative_to(fuzzforge_dir.parent)}")
template_path = fuzzforge_dir / ".env.template"
if not template_path.exists() or force:
template_lines = []
for line in env_lines:
if line.startswith("OPENAI_API_KEY="):
template_lines.append("OPENAI_API_KEY=")
elif line.startswith("LLM_COGNEE_API_KEY="):
template_lines.append("LLM_COGNEE_API_KEY=")
else:
template_lines.append(line)
template_path.write_text("\n".join(template_lines), encoding="utf-8")
console.print(f"📝 Created {template_path.relative_to(fuzzforge_dir.parent)}")
# SQLite session DB will be created automatically when first used by the AI agent
def _ensure_agents_registry(fuzzforge_dir: Path, force: bool) -> None:
"""Create a starter agents.yaml registry if needed."""
agents_path = fuzzforge_dir / "agents.yaml"
if agents_path.exists() and not force:
return
template = dedent(
"""\
# FuzzForge Registered Agents
# Populate this list to auto-register remote agents when the AI CLI starts
registered_agents: []
# Example:
# registered_agents:
# - name: Calculator
# url: http://localhost:10201
# description: Sample math agent
""".strip()
)
agents_path.write_text(template + "\n", encoding="utf-8")
console.print(f"📝 Created {agents_path.relative_to(fuzzforge_dir.parent)}")
+436
View File
@@ -0,0 +1,436 @@
"""
Real-time monitoring and statistics commands.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import asyncio
import time
from datetime import datetime, timedelta
from typing import Optional
import typer
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.live import Live
from rich.layout import Layout
from rich.progress import Progress, BarColumn, TextColumn, SpinnerColumn
from rich.align import Align
from rich import box
from ..config import get_project_config, FuzzForgeConfig
from ..database import get_project_db, ensure_project_db, CrashRecord
from fuzzforge_sdk import FuzzForgeClient
console = Console()
app = typer.Typer()
def get_client() -> FuzzForgeClient:
"""Get configured FuzzForge client"""
config = get_project_config() or FuzzForgeConfig()
return FuzzForgeClient(base_url=config.get_api_url(), timeout=config.get_timeout())
def format_duration(seconds: int) -> str:
"""Format duration in human readable format"""
if seconds < 60:
return f"{seconds}s"
elif seconds < 3600:
return f"{seconds // 60}m {seconds % 60}s"
else:
hours = seconds // 3600
minutes = (seconds % 3600) // 60
return f"{hours}h {minutes}m"
def format_number(num: int) -> str:
"""Format large numbers with K, M suffixes"""
if num >= 1000000:
return f"{num / 1000000:.1f}M"
elif num >= 1000:
return f"{num / 1000:.1f}K"
else:
return str(num)
@app.command("stats")
def fuzzing_stats(
run_id: str = typer.Argument(..., help="Run ID to get statistics for"),
refresh: int = typer.Option(
5, "--refresh", "-r",
help="Refresh interval in seconds"
),
once: bool = typer.Option(
False, "--once",
help="Show stats once and exit"
)
):
"""
📊 Show current fuzzing statistics for a run
"""
try:
with get_client() as client:
if once:
# Show stats once
stats = client.get_fuzzing_stats(run_id)
display_stats_table(stats)
else:
# Live updating stats
console.print(f"📊 [bold]Live Fuzzing Statistics[/bold] (Run: {run_id[:12]}...)")
console.print(f"Refreshing every {refresh}s. Press Ctrl+C to stop.\n")
with Live(auto_refresh=False, console=console) as live:
while True:
try:
stats = client.get_fuzzing_stats(run_id)
table = create_stats_table(stats)
live.update(table, refresh=True)
time.sleep(refresh)
except KeyboardInterrupt:
console.print("\n📊 Monitoring stopped", style="yellow")
break
except Exception as e:
console.print(f"❌ Failed to get fuzzing stats: {e}", style="red")
raise typer.Exit(1)
def display_stats_table(stats):
"""Display stats in a simple table"""
table = create_stats_table(stats)
console.print(table)
def create_stats_table(stats) -> Panel:
"""Create a rich table for fuzzing statistics"""
# Create main stats table
stats_table = Table(show_header=False, box=box.SIMPLE)
stats_table.add_column("Metric", style="bold cyan")
stats_table.add_column("Value", justify="right", style="bold white")
stats_table.add_row("Total Executions", format_number(stats.executions))
stats_table.add_row("Executions/sec", f"{stats.executions_per_sec:.1f}")
stats_table.add_row("Total Crashes", format_number(stats.crashes))
stats_table.add_row("Unique Crashes", format_number(stats.unique_crashes))
if stats.coverage is not None:
stats_table.add_row("Code Coverage", f"{stats.coverage:.1f}%")
stats_table.add_row("Corpus Size", format_number(stats.corpus_size))
stats_table.add_row("Elapsed Time", format_duration(stats.elapsed_time))
if stats.last_crash_time:
time_since_crash = datetime.now() - stats.last_crash_time
stats_table.add_row("Last Crash", f"{format_duration(int(time_since_crash.total_seconds()))} ago")
return Panel.fit(
stats_table,
title=f"📊 Fuzzing Statistics - {stats.workflow}",
subtitle=f"Run: {stats.run_id[:12]}...",
box=box.ROUNDED
)
@app.command("crashes")
def crash_reports(
run_id: str = typer.Argument(..., help="Run ID to get crash reports for"),
save: bool = typer.Option(
True, "--save/--no-save",
help="Save crashes to local database"
),
limit: int = typer.Option(
50, "--limit", "-l",
help="Maximum number of crashes to show"
)
):
"""
🐛 Display crash reports for a fuzzing run
"""
try:
with get_client() as client:
console.print(f"🐛 Fetching crash reports for run: {run_id}")
crashes = client.get_crash_reports(run_id)
if not crashes:
console.print("✅ No crashes found!", style="green")
return
# Save to database if requested
if save:
db = ensure_project_db()
for crash in crashes:
crash_record = CrashRecord(
run_id=run_id,
crash_id=crash.crash_id,
signal=crash.signal,
stack_trace=crash.stack_trace,
input_file=crash.input_file,
severity=crash.severity,
timestamp=crash.timestamp
)
db.save_crash(crash_record)
console.print("✅ Crashes saved to local database")
# Display crashes
crashes_to_show = crashes[:limit]
# Summary
severity_counts = {}
signal_counts = {}
for crash in crashes:
severity_counts[crash.severity] = severity_counts.get(crash.severity, 0) + 1
if crash.signal:
signal_counts[crash.signal] = signal_counts.get(crash.signal, 0) + 1
summary_table = Table(show_header=False, box=box.SIMPLE)
summary_table.add_column("Metric", style="bold cyan")
summary_table.add_column("Value", justify="right")
summary_table.add_row("Total Crashes", str(len(crashes)))
summary_table.add_row("Unique Signals", str(len(signal_counts)))
for severity, count in sorted(severity_counts.items()):
summary_table.add_row(f"{severity.title()} Severity", str(count))
console.print(
Panel.fit(
summary_table,
title=f"🐛 Crash Summary",
box=box.ROUNDED
)
)
# Detailed crash table
if crashes_to_show:
crashes_table = Table(box=box.ROUNDED)
crashes_table.add_column("Crash ID", style="bold cyan")
crashes_table.add_column("Signal", justify="center")
crashes_table.add_column("Severity", justify="center")
crashes_table.add_column("Timestamp", justify="center")
crashes_table.add_column("Input File", style="dim")
for crash in crashes_to_show:
signal_emoji = {
"SIGSEGV": "💥",
"SIGABRT": "🛑",
"SIGFPE": "🧮",
"SIGILL": "⚠️"
}.get(crash.signal or "", "🐛")
severity_style = {
"high": "red",
"medium": "yellow",
"low": "green"
}.get(crash.severity.lower(), "white")
input_display = ""
if crash.input_file:
input_display = crash.input_file.split("/")[-1] # Show just filename
crashes_table.add_row(
crash.crash_id[:12] + "..." if len(crash.crash_id) > 15 else crash.crash_id,
f"{signal_emoji} {crash.signal or 'Unknown'}",
f"[{severity_style}]{crash.severity}[/{severity_style}]",
crash.timestamp.strftime("%H:%M:%S"),
input_display
)
console.print(f"\n🐛 [bold]Crash Details[/bold]")
if len(crashes) > limit:
console.print(f"Showing first {limit} of {len(crashes)} crashes")
console.print()
console.print(crashes_table)
console.print(f"\n💡 Use [bold cyan]fuzzforge finding {run_id}[/bold cyan] for detailed analysis")
except Exception as e:
console.print(f"❌ Failed to get crash reports: {e}", style="red")
raise typer.Exit(1)
def _live_monitor(run_id: str, refresh: int):
"""Helper for live monitoring to allow for cleaner exit handling"""
with get_client() as client:
start_time = time.time()
def render_layout(run_status, stats):
layout = Layout()
layout.split_column(
Layout(name="header", size=3),
Layout(name="main", ratio=1),
Layout(name="footer", size=3)
)
layout["main"].split_row(
Layout(name="stats", ratio=1),
Layout(name="progress", ratio=1)
)
header = Panel(
f"[bold]FuzzForge Live Monitor[/bold]\n"
f"Run: {run_id[:12]}... | Status: {run_status.status} | "
f"Uptime: {format_duration(int(time.time() - start_time))}",
box=box.ROUNDED,
style="cyan"
)
layout["header"].update(header)
layout["stats"].update(create_stats_table(stats))
progress_table = Table(show_header=False, box=box.SIMPLE)
progress_table.add_column("Metric", style="bold")
progress_table.add_column("Progress")
if stats.executions > 0:
exec_rate_percent = min(100, (stats.executions_per_sec / 1000) * 100)
progress_table.add_row("Exec Rate", create_progress_bar(exec_rate_percent, "green"))
crash_rate = (stats.crashes / stats.executions) * 100000
crash_rate_percent = min(100, crash_rate * 10)
progress_table.add_row("Crash Rate", create_progress_bar(crash_rate_percent, "red"))
if stats.coverage is not None:
progress_table.add_row("Coverage", create_progress_bar(stats.coverage, "blue"))
layout["progress"].update(Panel.fit(progress_table, title="📊 Progress Indicators", box=box.ROUNDED))
footer = Panel(
f"Last updated: {datetime.now().strftime('%H:%M:%S')} | "
f"Refresh interval: {refresh}s | Press Ctrl+C to exit",
box=box.ROUNDED,
style="dim"
)
layout["footer"].update(footer)
return layout
with Live(auto_refresh=False, console=console, screen=True) as live:
# Initial fetch
try:
run_status = client.get_run_status(run_id)
stats = client.get_fuzzing_stats(run_id)
except Exception:
# Minimal fallback stats
class FallbackStats:
def __init__(self, run_id):
self.run_id = run_id
self.workflow = "unknown"
self.executions = 0
self.executions_per_sec = 0.0
self.crashes = 0
self.unique_crashes = 0
self.coverage = None
self.corpus_size = 0
self.elapsed_time = 0
self.last_crash_time = None
stats = FallbackStats(run_id)
run_status = type("RS", (), {"status":"Unknown","is_completed":False,"is_failed":False})()
live.update(render_layout(run_status, stats), refresh=True)
# Simple polling approach that actually works
consecutive_errors = 0
max_errors = 5
while True:
try:
# Poll for updates
try:
run_status = client.get_run_status(run_id)
consecutive_errors = 0
except Exception as e:
consecutive_errors += 1
if consecutive_errors >= max_errors:
console.print(f"❌ Too many errors getting run status: {e}", style="red")
break
time.sleep(refresh)
continue
# Try to get fuzzing stats
try:
stats = client.get_fuzzing_stats(run_id)
except Exception as e:
# Create fallback stats if not available
stats = FallbackStats(run_id)
# Update display
live.update(render_layout(run_status, stats), refresh=True)
# Check if completed
if getattr(run_status, 'is_completed', False) or getattr(run_status, 'is_failed', False):
# Show final state for a few seconds
console.print("\n🏁 Run completed. Showing final state for 10 seconds...")
time.sleep(10)
break
# Wait before next poll
time.sleep(refresh)
except KeyboardInterrupt:
raise
except Exception as e:
console.print(f"⚠️ Monitoring error: {e}", style="yellow")
time.sleep(refresh)
# Completed status update
final_message = (
f"[bold]FuzzForge Live Monitor - COMPLETED[/bold]\n"
f"Run: {run_id[:12]}... | Status: {run_status.status} | "
f"Total runtime: {format_duration(int(time.time() - start_time))}"
)
style = "green" if getattr(run_status, 'is_completed', False) else "red"
live.update(Panel(final_message, box=box.ROUNDED, style=style), refresh=True)
@app.command("live")
def live_monitor(
run_id: str = typer.Argument(..., help="Run ID to monitor live"),
refresh: int = typer.Option(
2, "--refresh", "-r",
help="Refresh interval in seconds (fallback when streaming unavailable)"
)
):
"""
📺 Real-time monitoring dashboard with live updates (WebSocket/SSE with REST fallback)
"""
console.print(f"📺 [bold]Live Monitoring Dashboard[/bold]")
console.print(f"Run: {run_id}")
console.print(f"Press Ctrl+C to stop monitoring\n")
try:
_live_monitor(run_id, refresh)
except KeyboardInterrupt:
console.print("\n📊 Monitoring stopped by user.", style="yellow")
except Exception as e:
console.print(f"❌ Failed to start live monitoring: {e}", style="red")
raise typer.Exit(1)
def create_progress_bar(percentage: float, color: str = "green") -> str:
"""Create a simple text progress bar"""
width = 20
filled = int((percentage / 100) * width)
bar = "" * filled + "" * (width - filled)
return f"[{color}]{bar}[/{color}] {percentage:.1f}%"
@app.callback(invoke_without_command=True)
def monitor_callback(ctx: typer.Context):
"""
📊 Real-time monitoring and statistics
"""
# Check if a subcommand is being invoked
if ctx.invoked_subcommand is not None:
# Let the subcommand handle it
return
# Show not implemented message for default command
from rich.console import Console
console = Console()
console.print("🚧 [yellow]Monitor command is not fully implemented yet.[/yellow]")
console.print("Please use specific subcommands:")
console.print(" • [cyan]ff monitor stats <run-id>[/cyan] - Show execution statistics")
console.print(" • [cyan]ff monitor crashes <run-id>[/cyan] - Show crash reports")
console.print(" • [cyan]ff monitor live <run-id>[/cyan] - Live monitoring dashboard")
+165
View File
@@ -0,0 +1,165 @@
"""
Status command for showing project and API information.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
from pathlib import Path
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich import box
from ..config import get_project_config, FuzzForgeConfig
from ..database import get_project_db
from fuzzforge_sdk import FuzzForgeClient
console = Console()
def show_status():
"""Show comprehensive project and API status"""
current_dir = Path.cwd()
fuzzforge_dir = current_dir / ".fuzzforge"
# Project status
console.print("\n📊 [bold]FuzzForge Project Status[/bold]\n")
if not fuzzforge_dir.exists():
console.print(
Panel.fit(
"❌ No FuzzForge project found in current directory\n\n"
"Run [bold cyan]ff init[/bold cyan] to initialize a project",
title="Project Status",
box=box.ROUNDED
)
)
return
# Load project configuration
config = get_project_config()
if not config:
config = FuzzForgeConfig()
# Project info table
project_table = Table(show_header=False, box=box.SIMPLE)
project_table.add_column("Property", style="bold cyan")
project_table.add_column("Value")
project_table.add_row("Project Name", config.project.name)
project_table.add_row("Location", str(current_dir))
project_table.add_row("API URL", config.project.api_url)
project_table.add_row("Default Timeout", f"{config.project.default_timeout}s")
console.print(
Panel.fit(
project_table,
title="✅ Project Information",
box=box.ROUNDED
)
)
# Database status
db = get_project_db()
if db:
try:
stats = db.get_stats()
db_table = Table(show_header=False, box=box.SIMPLE)
db_table.add_column("Metric", style="bold cyan")
db_table.add_column("Count", justify="right")
db_table.add_row("Total Runs", str(stats["total_runs"]))
db_table.add_row("Total Findings", str(stats["total_findings"]))
db_table.add_row("Total Crashes", str(stats["total_crashes"]))
db_table.add_row("Runs (Last 7 days)", str(stats["runs_last_7_days"]))
if stats["runs_by_status"]:
db_table.add_row("", "") # Spacer
for status, count in stats["runs_by_status"].items():
status_emoji = {
"completed": "",
"running": "🔄",
"failed": "",
"queued": "",
"cancelled": "⏹️"
}.get(status, "📋")
db_table.add_row(f"{status_emoji} {status.title()}", str(count))
console.print(
Panel.fit(
db_table,
title="🗄️ Database Statistics",
box=box.ROUNDED
)
)
except Exception as e:
console.print(f"⚠️ Database error: {e}", style="yellow")
# API status
console.print("\n🔗 [bold]API Connectivity[/bold]")
try:
with FuzzForgeClient(base_url=config.get_api_url(), timeout=10.0) as client:
api_status = client.get_api_status()
workflows = client.list_workflows()
api_table = Table(show_header=False, box=box.SIMPLE)
api_table.add_column("Property", style="bold cyan")
api_table.add_column("Value")
api_table.add_row("Status", f"✅ Connected")
api_table.add_row("Service", f"{api_status.name} v{api_status.version}")
api_table.add_row("Workflows", str(len(workflows)))
console.print(
Panel.fit(
api_table,
title="✅ API Status",
box=box.ROUNDED
)
)
# Show available workflows
if workflows:
workflow_table = Table(box=box.SIMPLE_HEAD)
workflow_table.add_column("Name", style="bold")
workflow_table.add_column("Version", justify="center")
workflow_table.add_column("Description")
for workflow in workflows[:10]: # Limit to first 10
workflow_table.add_row(
workflow.name,
workflow.version,
workflow.description[:60] + "..." if len(workflow.description) > 60 else workflow.description
)
if len(workflows) > 10:
workflow_table.add_row("...", "...", f"and {len(workflows) - 10} more workflows")
console.print(
Panel.fit(
workflow_table,
title=f"🔧 Available Workflows ({len(workflows)})",
box=box.ROUNDED
)
)
except Exception as e:
console.print(
Panel.fit(
f"❌ Failed to connect to API\n\n"
f"Error: {str(e)}\n\n"
f"API URL: {config.get_api_url()}\n\n"
"Check that the FuzzForge API is running and accessible.",
title="❌ API Connection Failed",
box=box.ROUNDED
)
)

Some files were not shown because too many files have changed in this diff Show More