Initial commit

This commit is contained in:
Tanguy Duhamel
2025-09-29 21:26:41 +02:00
parent ecf8d49dde
commit 0547b78429
208 changed files with 72069 additions and 53 deletions
@@ -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))
}