mirror of
https://github.com/FuzzingLabs/fuzzforge_ai.git
synced 2026-07-09 20:58:40 +02:00
feat: Add Python SAST workflow with three security analysis tools
Implements Issue #5 - Python SAST workflow that combines: - Dependency scanning (pip-audit) for CVE detection - Security linting (Bandit) for vulnerability patterns - Type checking (Mypy) for type safety issues ## Changes **New Modules:** - `DependencyScanner`: Scans Python dependencies for known CVEs using pip-audit - `BanditAnalyzer`: Analyzes Python code for security issues using Bandit - `MypyAnalyzer`: Checks Python code for type safety issues using Mypy **New Workflow:** - `python_sast`: Temporal workflow that orchestrates all three SAST tools - Runs tools in parallel for fast feedback (3-5 min vs hours for fuzzing) - Generates unified SARIF report with findings from all tools - Supports configurable severity/confidence thresholds **Updates:** - Added SAST dependencies to Python worker (bandit, pip-audit, mypy) - Updated module __init__.py files to export new analyzers - Added type_errors.py test file to vulnerable_app for Mypy validation ## Testing Workflow tested successfully on vulnerable_app: - ✅ Bandit: Detected 9 security issues (command injection, unsafe functions) - ✅ Mypy: Detected 5 type errors - ✅ DependencyScanner: Ran successfully (no CVEs in test dependencies) - ✅ SARIF export: Generated valid SARIF with 14 total findings
This commit is contained in:
@@ -10,5 +10,6 @@
|
||||
# Additional attribution and requirements are provided in the NOTICE file.
|
||||
|
||||
from .file_scanner import FileScanner
|
||||
from .dependency_scanner import DependencyScanner
|
||||
|
||||
__all__ = ["FileScanner"]
|
||||
__all__ = ["FileScanner", "DependencyScanner"]
|
||||
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
Dependency Scanner Module - Scans Python dependencies for known vulnerabilities using pip-audit
|
||||
"""
|
||||
|
||||
# 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
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List
|
||||
import uuid
|
||||
|
||||
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 DependencyScanner(BaseModule):
|
||||
"""
|
||||
Scans Python dependencies for known vulnerabilities using pip-audit.
|
||||
|
||||
This module:
|
||||
- Discovers dependency files (requirements.txt, pyproject.toml, setup.py, Pipfile)
|
||||
- Runs pip-audit to check for vulnerable dependencies
|
||||
- Reports CVEs with severity and affected versions
|
||||
"""
|
||||
|
||||
def get_metadata(self) -> ModuleMetadata:
|
||||
"""Get module metadata"""
|
||||
return ModuleMetadata(
|
||||
name="dependency_scanner",
|
||||
version="1.0.0",
|
||||
description="Scans Python dependencies for known vulnerabilities",
|
||||
author="FuzzForge Team",
|
||||
category="scanner",
|
||||
tags=["dependencies", "cve", "vulnerabilities", "pip-audit"],
|
||||
input_schema={
|
||||
"dependency_files": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of dependency files to scan (auto-discovered if empty)",
|
||||
"default": []
|
||||
},
|
||||
"ignore_vulns": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of vulnerability IDs to ignore",
|
||||
"default": []
|
||||
}
|
||||
},
|
||||
output_schema={
|
||||
"findings": {
|
||||
"type": "array",
|
||||
"description": "List of vulnerable dependencies with CVE information"
|
||||
}
|
||||
},
|
||||
requires_workspace=True
|
||||
)
|
||||
|
||||
def validate_config(self, config: Dict[str, Any]) -> bool:
|
||||
"""Validate module configuration"""
|
||||
dep_files = config.get("dependency_files", [])
|
||||
if not isinstance(dep_files, list):
|
||||
raise ValueError("dependency_files must be a list")
|
||||
|
||||
ignore_vulns = config.get("ignore_vulns", [])
|
||||
if not isinstance(ignore_vulns, list):
|
||||
raise ValueError("ignore_vulns must be a list")
|
||||
|
||||
return True
|
||||
|
||||
def _discover_dependency_files(self, workspace: Path) -> List[Path]:
|
||||
"""
|
||||
Discover Python dependency files in workspace.
|
||||
|
||||
Returns:
|
||||
List of discovered dependency file paths
|
||||
"""
|
||||
dependency_patterns = [
|
||||
"requirements.txt",
|
||||
"*requirements*.txt",
|
||||
"pyproject.toml",
|
||||
"setup.py",
|
||||
"Pipfile",
|
||||
"poetry.lock"
|
||||
]
|
||||
|
||||
found_files = []
|
||||
for pattern in dependency_patterns:
|
||||
found_files.extend(workspace.rglob(pattern))
|
||||
|
||||
# Deduplicate and return
|
||||
unique_files = list(set(found_files))
|
||||
logger.info(f"Discovered {len(unique_files)} dependency files")
|
||||
return unique_files
|
||||
|
||||
async def _run_pip_audit(self, file_path: Path) -> Dict[str, Any]:
|
||||
"""
|
||||
Run pip-audit on a specific dependency file.
|
||||
|
||||
Args:
|
||||
file_path: Path to dependency file
|
||||
|
||||
Returns:
|
||||
pip-audit JSON output as dict
|
||||
"""
|
||||
try:
|
||||
# Run pip-audit with JSON output
|
||||
cmd = [
|
||||
"pip-audit",
|
||||
"--requirement", str(file_path),
|
||||
"--format", "json",
|
||||
"--progress-spinner", "off"
|
||||
]
|
||||
|
||||
logger.info(f"Running pip-audit on: {file_path.name}")
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
# pip-audit returns 0 if no vulns, 1 if vulns found
|
||||
if process.returncode not in [0, 1]:
|
||||
logger.error(f"pip-audit failed: {stderr.decode()}")
|
||||
return {"dependencies": []}
|
||||
|
||||
# Parse JSON output
|
||||
result = json.loads(stdout.decode())
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error running pip-audit on {file_path}: {e}")
|
||||
return {"dependencies": []}
|
||||
|
||||
def _convert_to_findings(
|
||||
self,
|
||||
audit_result: Dict[str, Any],
|
||||
file_path: Path,
|
||||
workspace: Path,
|
||||
ignore_vulns: List[str]
|
||||
) -> List[ModuleFinding]:
|
||||
"""
|
||||
Convert pip-audit results to ModuleFindings.
|
||||
|
||||
Args:
|
||||
audit_result: pip-audit JSON output
|
||||
file_path: Path to scanned file
|
||||
workspace: Workspace path for relative path calculation
|
||||
ignore_vulns: List of vulnerability IDs to ignore
|
||||
|
||||
Returns:
|
||||
List of ModuleFindings
|
||||
"""
|
||||
findings = []
|
||||
|
||||
# pip-audit format: {"dependencies": [{package, version, vulns: []}]}
|
||||
for dep in audit_result.get("dependencies", []):
|
||||
package_name = dep.get("name", "unknown")
|
||||
package_version = dep.get("version", "unknown")
|
||||
vulnerabilities = dep.get("vulns", [])
|
||||
|
||||
for vuln in vulnerabilities:
|
||||
vuln_id = vuln.get("id", "UNKNOWN")
|
||||
|
||||
# Skip if in ignore list
|
||||
if vuln_id in ignore_vulns:
|
||||
logger.debug(f"Ignoring vulnerability: {vuln_id}")
|
||||
continue
|
||||
|
||||
description = vuln.get("description", "No description available")
|
||||
fix_versions = vuln.get("fix_versions", [])
|
||||
|
||||
# Map CVSS scores to severity
|
||||
# pip-audit doesn't always provide CVSS, so we default to medium
|
||||
severity = "medium"
|
||||
|
||||
# Try to get relative path
|
||||
try:
|
||||
rel_path = file_path.relative_to(workspace)
|
||||
except ValueError:
|
||||
rel_path = file_path
|
||||
|
||||
finding_id = str(uuid.uuid4())
|
||||
recommendation = f"Upgrade {package_name} to a fixed version: {', '.join(fix_versions)}" if fix_versions else f"Check for updates to {package_name}"
|
||||
|
||||
finding = self.create_finding(
|
||||
title=f"Vulnerable dependency: {package_name} ({vuln_id})",
|
||||
description=f"{description}\n\nAffected package: {package_name} {package_version}",
|
||||
severity=severity,
|
||||
category="vulnerable-dependency",
|
||||
file_path=str(rel_path),
|
||||
recommendation=recommendation,
|
||||
metadata={
|
||||
"cve_id": vuln_id,
|
||||
"package": package_name,
|
||||
"installed_version": package_version,
|
||||
"fix_versions": fix_versions,
|
||||
"aliases": vuln.get("aliases", []),
|
||||
"link": vuln.get("link", "")
|
||||
}
|
||||
)
|
||||
findings.append(finding)
|
||||
|
||||
return findings
|
||||
|
||||
async def execute(self, config: Dict[str, Any], workspace: Path) -> ModuleResult:
|
||||
"""
|
||||
Execute the dependency scanning module.
|
||||
|
||||
Args:
|
||||
config: Module configuration
|
||||
workspace: Path to workspace
|
||||
|
||||
Returns:
|
||||
ModuleResult with vulnerability findings
|
||||
"""
|
||||
start_time = time.time()
|
||||
metadata = self.get_metadata()
|
||||
|
||||
# Validate inputs
|
||||
self.validate_config(config)
|
||||
self.validate_workspace(workspace)
|
||||
|
||||
# Get configuration
|
||||
specified_files = config.get("dependency_files", [])
|
||||
ignore_vulns = config.get("ignore_vulns", [])
|
||||
|
||||
# Discover or use specified dependency files
|
||||
if specified_files:
|
||||
dep_files = [workspace / f for f in specified_files]
|
||||
else:
|
||||
dep_files = self._discover_dependency_files(workspace)
|
||||
|
||||
if not dep_files:
|
||||
logger.warning("No dependency files found in workspace")
|
||||
return ModuleResult(
|
||||
module=metadata.name,
|
||||
version=metadata.version,
|
||||
status="success",
|
||||
execution_time=time.time() - start_time,
|
||||
findings=[],
|
||||
summary={
|
||||
"total_files": 0,
|
||||
"total_vulnerabilities": 0,
|
||||
"vulnerable_packages": 0
|
||||
}
|
||||
)
|
||||
|
||||
# Scan each dependency file
|
||||
all_findings = []
|
||||
files_scanned = 0
|
||||
|
||||
for dep_file in dep_files:
|
||||
if not dep_file.exists():
|
||||
logger.warning(f"Dependency file not found: {dep_file}")
|
||||
continue
|
||||
|
||||
logger.info(f"Scanning dependencies in: {dep_file.name}")
|
||||
audit_result = await self._run_pip_audit(dep_file)
|
||||
findings = self._convert_to_findings(audit_result, dep_file, workspace, ignore_vulns)
|
||||
|
||||
all_findings.extend(findings)
|
||||
files_scanned += 1
|
||||
|
||||
# Calculate summary
|
||||
unique_packages = len(set(f.metadata.get("package") for f in all_findings))
|
||||
|
||||
execution_time = time.time() - start_time
|
||||
|
||||
return ModuleResult(
|
||||
module=metadata.name,
|
||||
version=metadata.version,
|
||||
status="success",
|
||||
execution_time=execution_time,
|
||||
findings=all_findings,
|
||||
summary={
|
||||
"total_files": files_scanned,
|
||||
"total_vulnerabilities": len(all_findings),
|
||||
"vulnerable_packages": unique_packages
|
||||
},
|
||||
metadata={
|
||||
"scanned_files": [str(f.name) for f in dep_files if f.exists()]
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user