mirror of
https://github.com/FuzzingLabs/fuzzforge_ai.git
synced 2026-08-10 02:50:36 +02:00
Initial commit
This commit is contained in:
+173
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
|
||||

|
||||
<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>
|
||||
|
||||
[](https://discord.com/invite/acqv9FVG)
|
||||
[](https://fuzzforge.ai)
|
||||
[](LICENSE)
|
||||

|
||||
<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
|
||||

|
||||
|
||||
*AI agents automatically analyzing code and providing security insights*
|
||||
|
||||
### Manual Workflow Setup
|
||||

|
||||
|
||||
*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
|
||||

|
||||
|
||||
*Setting up and running security workflows through the interface*
|
||||
|
||||
👉 More installation options in the [Documentation](https://fuzzforge.ai/docs).
|
||||
|
||||
---
|
||||
|
||||
## AI-Powered Workflow Execution
|
||||

|
||||
|
||||
*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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
fuzzforge_sessions.db
|
||||
agentops.log
|
||||
*.log
|
||||
+110
@@ -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
@@ -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.
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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']
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
Executable
+977
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
Binary file not shown.
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,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,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}"}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
]
|
||||
}
|
||||
@@ -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,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}")
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,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()
|
||||
@@ -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"
|
||||
@@ -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,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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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}
|
||||
))
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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()
|
||||
@@ -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 }
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)}")
|
||||
@@ -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")
|
||||
@@ -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
Reference in New Issue
Block a user