mirror of
https://github.com/FuzzingLabs/fuzzforge_ai.git
synced 2026-05-20 08:14:46 +02:00
Initial commit
This commit is contained in:
@@ -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
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,591 @@
|
||||
"""
|
||||
Workflow execution and management commands.
|
||||
Replaces the old 'runs' terminology with cleaner workflow-centric 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 time
|
||||
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
|
||||
from rich.panel import Panel
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
|
||||
from rich.prompt import Prompt, Confirm
|
||||
from rich.live import Live
|
||||
from rich import box
|
||||
|
||||
from ..config import get_project_config, FuzzForgeConfig
|
||||
from ..database import get_project_db, ensure_project_db, RunRecord
|
||||
from ..exceptions import (
|
||||
handle_error, retry_on_network_error, safe_json_load, require_project,
|
||||
APIConnectionError, ValidationError, DatabaseError, FileOperationError
|
||||
)
|
||||
from ..validation import (
|
||||
validate_run_id, validate_workflow_name, validate_target_path,
|
||||
validate_volume_mode, validate_parameters, validate_timeout
|
||||
)
|
||||
from ..progress import progress_manager, spinner, step_progress
|
||||
from ..completion import WorkflowNameComplete, TargetPathComplete, VolumeModetComplete
|
||||
from ..constants import (
|
||||
STATUS_EMOJIS, MAX_RUN_ID_DISPLAY_LENGTH, DEFAULT_VOLUME_MODE,
|
||||
PROGRESS_STEP_DELAYS, MAX_RETRIES, RETRY_DELAY, POLL_INTERVAL
|
||||
)
|
||||
from fuzzforge_sdk import FuzzForgeClient, WorkflowSubmission
|
||||
|
||||
console = Console()
|
||||
app = typer.Typer()
|
||||
|
||||
|
||||
@retry_on_network_error(max_retries=MAX_RETRIES, delay=RETRY_DELAY)
|
||||
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 status_emoji(status: str) -> str:
|
||||
"""Get emoji for execution status"""
|
||||
return STATUS_EMOJIS.get(status.lower(), STATUS_EMOJIS["unknown"])
|
||||
|
||||
|
||||
def parse_inline_parameters(params: List[str]) -> Dict[str, Any]:
|
||||
"""Parse inline key=value parameters using improved validation"""
|
||||
return validate_parameters(params)
|
||||
|
||||
|
||||
def execute_workflow_submission(
|
||||
client: FuzzForgeClient,
|
||||
workflow: str,
|
||||
target_path: str,
|
||||
parameters: Dict[str, Any],
|
||||
volume_mode: str,
|
||||
timeout: Optional[int],
|
||||
interactive: bool
|
||||
) -> Any:
|
||||
"""Handle the workflow submission process"""
|
||||
# Get workflow metadata for parameter validation
|
||||
console.print(f"🔧 Getting workflow information for: {workflow}")
|
||||
workflow_meta = client.get_workflow_metadata(workflow)
|
||||
param_response = client.get_workflow_parameters(workflow)
|
||||
|
||||
# Interactive parameter input
|
||||
if interactive and workflow_meta.parameters.get("properties"):
|
||||
properties = workflow_meta.parameters.get("properties", {})
|
||||
required_params = set(workflow_meta.parameters.get("required", []))
|
||||
defaults = param_response.defaults
|
||||
|
||||
missing_required = required_params - set(parameters.keys())
|
||||
|
||||
if missing_required:
|
||||
console.print(f"\n📝 [bold]Missing required parameters:[/bold] {', '.join(missing_required)}")
|
||||
console.print("Please provide values:\n")
|
||||
|
||||
for param_name in missing_required:
|
||||
param_schema = properties.get(param_name, {})
|
||||
description = param_schema.get("description", "")
|
||||
param_type = param_schema.get("type", "string")
|
||||
|
||||
prompt_text = f"{param_name}"
|
||||
if description:
|
||||
prompt_text += f" ({description})"
|
||||
prompt_text += f" [{param_type}]"
|
||||
|
||||
while True:
|
||||
user_input = Prompt.ask(prompt_text, console=console)
|
||||
|
||||
try:
|
||||
if param_type == "integer":
|
||||
parameters[param_name] = int(user_input)
|
||||
elif param_type == "number":
|
||||
parameters[param_name] = float(user_input)
|
||||
elif param_type == "boolean":
|
||||
parameters[param_name] = user_input.lower() in ("true", "yes", "1", "on")
|
||||
elif param_type == "array":
|
||||
parameters[param_name] = [item.strip() for item in user_input.split(",") if item.strip()]
|
||||
else:
|
||||
parameters[param_name] = user_input
|
||||
break
|
||||
except ValueError as e:
|
||||
console.print(f"❌ Invalid {param_type}: {e}", style="red")
|
||||
|
||||
# Validate volume mode
|
||||
validate_volume_mode(volume_mode)
|
||||
if volume_mode not in workflow_meta.supported_volume_modes:
|
||||
raise ValidationError(
|
||||
"volume mode", volume_mode,
|
||||
f"one of: {', '.join(workflow_meta.supported_volume_modes)}"
|
||||
)
|
||||
|
||||
# Create submission
|
||||
submission = WorkflowSubmission(
|
||||
target_path=target_path,
|
||||
volume_mode=volume_mode,
|
||||
parameters=parameters,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
# Show submission summary
|
||||
console.print(f"\n🎯 [bold]Executing workflow:[/bold]")
|
||||
console.print(f" Workflow: {workflow}")
|
||||
console.print(f" Target: {target_path}")
|
||||
console.print(f" Volume Mode: {volume_mode}")
|
||||
if parameters:
|
||||
console.print(f" Parameters: {len(parameters)} provided")
|
||||
if timeout:
|
||||
console.print(f" Timeout: {timeout}s")
|
||||
|
||||
# Only ask for confirmation in interactive mode
|
||||
if interactive:
|
||||
if not Confirm.ask("\nExecute workflow?", default=True, console=console):
|
||||
console.print("❌ Execution cancelled", style="yellow")
|
||||
raise typer.Exit(0)
|
||||
else:
|
||||
console.print("\n🚀 Executing workflow...")
|
||||
|
||||
# Submit the workflow with enhanced progress
|
||||
console.print(f"\n🚀 Executing workflow: [bold yellow]{workflow}[/bold yellow]")
|
||||
|
||||
steps = [
|
||||
"Validating workflow configuration",
|
||||
"Connecting to FuzzForge API",
|
||||
"Uploading parameters and settings",
|
||||
"Creating workflow deployment",
|
||||
"Initializing execution environment"
|
||||
]
|
||||
|
||||
with step_progress(steps, f"Executing {workflow}") as progress:
|
||||
progress.next_step() # Validating
|
||||
time.sleep(PROGRESS_STEP_DELAYS["validating"])
|
||||
|
||||
progress.next_step() # Connecting
|
||||
time.sleep(PROGRESS_STEP_DELAYS["connecting"])
|
||||
|
||||
progress.next_step() # Uploading
|
||||
response = client.submit_workflow(workflow, submission)
|
||||
time.sleep(PROGRESS_STEP_DELAYS["uploading"])
|
||||
|
||||
progress.next_step() # Creating deployment
|
||||
time.sleep(PROGRESS_STEP_DELAYS["creating"])
|
||||
|
||||
progress.next_step() # Initializing
|
||||
time.sleep(PROGRESS_STEP_DELAYS["initializing"])
|
||||
|
||||
progress.complete(f"Workflow started successfully!")
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# Main workflow execution command (replaces 'runs submit')
|
||||
@app.command(name="exec", hidden=True) # Hidden because it will be called from main workflow command
|
||||
def execute_workflow(
|
||||
workflow: str = typer.Argument(..., help="Workflow name to execute"),
|
||||
target_path: str = typer.Argument(..., help="Path to analyze"),
|
||||
params: List[str] = typer.Argument(default=None, help="Parameters as key=value pairs"),
|
||||
param_file: Optional[str] = typer.Option(
|
||||
None, "--param-file", "-f",
|
||||
help="JSON file containing workflow parameters"
|
||||
),
|
||||
volume_mode: str = typer.Option(
|
||||
DEFAULT_VOLUME_MODE, "--volume-mode", "-v",
|
||||
help="Volume mount mode: ro (read-only) or rw (read-write)"
|
||||
),
|
||||
timeout: Optional[int] = typer.Option(
|
||||
None, "--timeout", "-t",
|
||||
help="Execution timeout in seconds"
|
||||
),
|
||||
interactive: bool = typer.Option(
|
||||
True, "--interactive/--no-interactive", "-i/-n",
|
||||
help="Interactive parameter input for missing required parameters"
|
||||
),
|
||||
wait: bool = typer.Option(
|
||||
False, "--wait", "-w",
|
||||
help="Wait for execution to complete"
|
||||
),
|
||||
live: bool = typer.Option(
|
||||
False, "--live", "-l",
|
||||
help="Start live monitoring after execution (useful for fuzzing workflows)"
|
||||
)
|
||||
):
|
||||
"""
|
||||
🚀 Execute a workflow on a target
|
||||
|
||||
Use --live for fuzzing workflows to see real-time progress.
|
||||
Use --wait to wait for completion without live dashboard.
|
||||
"""
|
||||
try:
|
||||
# Validate inputs
|
||||
validate_workflow_name(workflow)
|
||||
target_path_obj = validate_target_path(target_path, must_exist=True)
|
||||
target_path = str(target_path_obj.absolute())
|
||||
validate_timeout(timeout)
|
||||
|
||||
# Ensure we're in a project directory
|
||||
require_project()
|
||||
except Exception as e:
|
||||
handle_error(e, "validating inputs")
|
||||
|
||||
# Parse parameters
|
||||
parameters = {}
|
||||
|
||||
# Load from param file
|
||||
if param_file:
|
||||
try:
|
||||
file_params = safe_json_load(param_file)
|
||||
if isinstance(file_params, dict):
|
||||
parameters.update(file_params)
|
||||
else:
|
||||
raise ValidationError("parameter file", param_file, "a JSON object")
|
||||
except Exception as e:
|
||||
handle_error(e, "loading parameter file")
|
||||
|
||||
# Parse inline parameters
|
||||
if params:
|
||||
try:
|
||||
inline_params = parse_inline_parameters(params)
|
||||
parameters.update(inline_params)
|
||||
except Exception as e:
|
||||
handle_error(e, "parsing parameters")
|
||||
|
||||
try:
|
||||
with get_client() as client:
|
||||
response = execute_workflow_submission(
|
||||
client, workflow, target_path, parameters,
|
||||
volume_mode, timeout, interactive
|
||||
)
|
||||
|
||||
console.print(f"✅ Workflow execution started!", style="green")
|
||||
console.print(f" Execution ID: [bold cyan]{response.run_id}[/bold cyan]")
|
||||
console.print(f" Status: {status_emoji(response.status)} {response.status}")
|
||||
|
||||
# Save to database
|
||||
try:
|
||||
db = ensure_project_db()
|
||||
run_record = RunRecord(
|
||||
run_id=response.run_id,
|
||||
workflow=workflow,
|
||||
status=response.status,
|
||||
target_path=target_path,
|
||||
parameters=parameters,
|
||||
created_at=datetime.now()
|
||||
)
|
||||
db.save_run(run_record)
|
||||
except Exception as e:
|
||||
# Don't fail the whole operation if database save fails
|
||||
console.print(f"⚠️ Failed to save execution to database: {e}", style="yellow")
|
||||
|
||||
console.print(f"\n💡 Monitor progress: [bold cyan]fuzzforge monitor {response.run_id}[/bold cyan]")
|
||||
console.print(f"💡 Check status: [bold cyan]fuzzforge workflow status {response.run_id}[/bold cyan]")
|
||||
|
||||
# Suggest --live for fuzzing workflows
|
||||
if not live and not wait and "fuzzing" in workflow.lower():
|
||||
console.print(f"💡 Next time try: [bold cyan]fuzzforge workflow {workflow} {target_path} --live[/bold cyan] for real-time fuzzing dashboard", style="dim")
|
||||
|
||||
# Start live monitoring if requested
|
||||
if live:
|
||||
# Check if this is a fuzzing workflow to show appropriate messaging
|
||||
is_fuzzing = "fuzzing" in workflow.lower()
|
||||
if is_fuzzing:
|
||||
console.print(f"\n📺 Starting live fuzzing dashboard...")
|
||||
console.print("💡 You'll see real-time crash discovery, execution stats, and coverage data.")
|
||||
else:
|
||||
console.print(f"\n📺 Starting live monitoring dashboard...")
|
||||
|
||||
console.print("Press Ctrl+C to stop monitoring (execution continues in background).\n")
|
||||
|
||||
try:
|
||||
from ..commands.monitor import live_monitor
|
||||
# Import monitor command and run it
|
||||
live_monitor(response.run_id, refresh=3)
|
||||
except KeyboardInterrupt:
|
||||
console.print(f"\n⏹️ Live monitoring stopped (execution continues in background)", style="yellow")
|
||||
except Exception as e:
|
||||
console.print(f"⚠️ Failed to start live monitoring: {e}", style="yellow")
|
||||
console.print(f"💡 You can still monitor manually: [bold cyan]fuzzforge monitor {response.run_id}[/bold cyan]")
|
||||
|
||||
# Wait for completion if requested
|
||||
elif wait:
|
||||
console.print(f"\n⏳ Waiting for execution to complete...")
|
||||
try:
|
||||
final_status = client.wait_for_completion(response.run_id, poll_interval=POLL_INTERVAL)
|
||||
|
||||
# Update database
|
||||
try:
|
||||
db.update_run_status(
|
||||
response.run_id,
|
||||
final_status.status,
|
||||
completed_at=datetime.now() if final_status.is_completed else None
|
||||
)
|
||||
except Exception as e:
|
||||
console.print(f"⚠️ Failed to update database: {e}", style="yellow")
|
||||
|
||||
console.print(f"🏁 Execution completed with status: {status_emoji(final_status.status)} {final_status.status}")
|
||||
|
||||
if final_status.is_completed:
|
||||
console.print(f"💡 View findings: [bold cyan]fuzzforge findings {response.run_id}[/bold cyan]")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
console.print(f"\n⏹️ Monitoring cancelled (execution continues in background)", style="yellow")
|
||||
except Exception as e:
|
||||
handle_error(e, "waiting for completion")
|
||||
|
||||
except Exception as e:
|
||||
handle_error(e, "executing workflow")
|
||||
|
||||
|
||||
@app.command("status")
|
||||
def workflow_status(
|
||||
execution_id: Optional[str] = typer.Argument(None, help="Execution ID to check (defaults to most recent)")
|
||||
):
|
||||
"""
|
||||
📊 Check the status of a workflow execution
|
||||
"""
|
||||
try:
|
||||
require_project()
|
||||
|
||||
if execution_id:
|
||||
validate_run_id(execution_id)
|
||||
|
||||
db = get_project_db()
|
||||
if not db:
|
||||
raise DatabaseError("get project database", Exception("No database found"))
|
||||
|
||||
# Get execution ID
|
||||
if not execution_id:
|
||||
recent_runs = db.list_runs(limit=1)
|
||||
if not recent_runs:
|
||||
console.print("⚠️ No executions found in project database", style="yellow")
|
||||
raise typer.Exit(0)
|
||||
execution_id = recent_runs[0].run_id
|
||||
console.print(f"🔍 Using most recent execution: {execution_id}")
|
||||
else:
|
||||
validate_run_id(execution_id)
|
||||
|
||||
# Get status from API
|
||||
with get_client() as client:
|
||||
status = client.get_run_status(execution_id)
|
||||
|
||||
# Update local database
|
||||
try:
|
||||
db.update_run_status(
|
||||
execution_id,
|
||||
status.status,
|
||||
completed_at=status.updated_at if status.is_completed else None
|
||||
)
|
||||
except Exception as e:
|
||||
console.print(f"⚠️ Failed to update database: {e}", style="yellow")
|
||||
|
||||
# Display status
|
||||
console.print(f"\n📊 [bold]Execution Status: {execution_id}[/bold]\n")
|
||||
|
||||
status_table = Table(show_header=False, box=box.SIMPLE)
|
||||
status_table.add_column("Property", style="bold cyan")
|
||||
status_table.add_column("Value")
|
||||
|
||||
status_table.add_row("Execution ID", execution_id)
|
||||
status_table.add_row("Workflow", status.workflow)
|
||||
status_table.add_row("Status", f"{status_emoji(status.status)} {status.status}")
|
||||
status_table.add_row("Created", status.created_at.strftime("%Y-%m-%d %H:%M:%S"))
|
||||
status_table.add_row("Updated", status.updated_at.strftime("%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
if status.is_completed:
|
||||
duration = status.updated_at - status.created_at
|
||||
status_table.add_row("Duration", str(duration).split('.')[0]) # Remove microseconds
|
||||
|
||||
console.print(
|
||||
Panel.fit(
|
||||
status_table,
|
||||
title=f"📊 Status Information",
|
||||
box=box.ROUNDED
|
||||
)
|
||||
)
|
||||
|
||||
# Show next steps
|
||||
if status.is_running:
|
||||
console.print(f"\n💡 Monitor live: [bold cyan]fuzzforge monitor {execution_id}[/bold cyan]")
|
||||
elif status.is_completed:
|
||||
console.print(f"💡 View findings: [bold cyan]fuzzforge finding {execution_id}[/bold cyan]")
|
||||
elif status.is_failed:
|
||||
console.print(f"💡 Check logs: [bold cyan]fuzzforge workflow logs {execution_id}[/bold cyan]")
|
||||
|
||||
except Exception as e:
|
||||
handle_error(e, "getting execution status")
|
||||
|
||||
|
||||
@app.command("history")
|
||||
def workflow_history(
|
||||
workflow: Optional[str] = typer.Option(None, "--workflow", "-w", help="Filter by workflow name"),
|
||||
status: Optional[str] = typer.Option(None, "--status", "-s", help="Filter by status"),
|
||||
limit: int = typer.Option(20, "--limit", "-l", help="Maximum number of executions to show")
|
||||
):
|
||||
"""
|
||||
📋 Show workflow execution history
|
||||
"""
|
||||
try:
|
||||
require_project()
|
||||
|
||||
if limit <= 0:
|
||||
raise ValidationError("limit", limit, "a positive integer")
|
||||
|
||||
db = get_project_db()
|
||||
if not db:
|
||||
raise DatabaseError("get project database", Exception("No database found"))
|
||||
runs = db.list_runs(workflow=workflow, status=status, limit=limit)
|
||||
|
||||
if not runs:
|
||||
console.print("⚠️ No executions found matching criteria", style="yellow")
|
||||
return
|
||||
|
||||
table = Table(box=box.ROUNDED)
|
||||
table.add_column("Execution ID", style="bold cyan")
|
||||
table.add_column("Workflow", style="bold")
|
||||
table.add_column("Status", justify="center")
|
||||
table.add_column("Target", style="dim")
|
||||
table.add_column("Created", justify="center")
|
||||
table.add_column("Parameters", justify="center", style="dim")
|
||||
|
||||
for run in runs:
|
||||
param_count = len(run.parameters) if run.parameters else 0
|
||||
param_str = f"{param_count} params" if param_count > 0 else "-"
|
||||
|
||||
table.add_row(
|
||||
run.run_id[:12] + "..." if len(run.run_id) > MAX_RUN_ID_DISPLAY_LENGTH else run.run_id,
|
||||
run.workflow,
|
||||
f"{status_emoji(run.status)} {run.status}",
|
||||
Path(run.target_path).name,
|
||||
run.created_at.strftime("%m-%d %H:%M"),
|
||||
param_str
|
||||
)
|
||||
|
||||
console.print(f"\n📋 [bold]Workflow Execution History ({len(runs)})[/bold]")
|
||||
if workflow:
|
||||
console.print(f" Filtered by workflow: {workflow}")
|
||||
if status:
|
||||
console.print(f" Filtered by status: {status}")
|
||||
console.print()
|
||||
console.print(table)
|
||||
|
||||
console.print(f"\n💡 Use [bold cyan]fuzzforge workflow status <execution-id>[/bold cyan] for detailed status")
|
||||
|
||||
except Exception as e:
|
||||
handle_error(e, "listing execution history")
|
||||
|
||||
|
||||
@app.command("retry")
|
||||
def retry_workflow(
|
||||
execution_id: Optional[str] = typer.Argument(None, help="Execution ID to retry (defaults to most recent)"),
|
||||
modify_params: bool = typer.Option(
|
||||
False, "--modify-params", "-m",
|
||||
help="Interactively modify parameters before retrying"
|
||||
)
|
||||
):
|
||||
"""
|
||||
🔄 Retry a workflow execution with the same or modified parameters
|
||||
"""
|
||||
try:
|
||||
require_project()
|
||||
|
||||
db = get_project_db()
|
||||
if not db:
|
||||
raise DatabaseError("get project database", Exception("No database found"))
|
||||
|
||||
# Get execution ID if not provided
|
||||
if not execution_id:
|
||||
recent_runs = db.list_runs(limit=1)
|
||||
if not recent_runs:
|
||||
console.print("⚠️ No executions found to retry", style="yellow")
|
||||
raise typer.Exit(0)
|
||||
execution_id = recent_runs[0].run_id
|
||||
console.print(f"🔄 Retrying most recent execution: {execution_id}")
|
||||
else:
|
||||
validate_run_id(execution_id)
|
||||
|
||||
# Get original execution
|
||||
original_run = db.get_run(execution_id)
|
||||
if not original_run:
|
||||
raise ValidationError("execution_id", execution_id, "an existing execution ID in the database")
|
||||
|
||||
console.print(f"🔄 [bold]Retrying workflow:[/bold] {original_run.workflow}")
|
||||
console.print(f" Original Execution ID: {execution_id}")
|
||||
console.print(f" Target: {original_run.target_path}")
|
||||
|
||||
parameters = original_run.parameters.copy()
|
||||
|
||||
# Modify parameters if requested
|
||||
if modify_params and parameters:
|
||||
console.print(f"\n📝 [bold]Current parameters:[/bold]")
|
||||
for key, value in parameters.items():
|
||||
new_value = Prompt.ask(
|
||||
f"{key}",
|
||||
default=str(value),
|
||||
console=console
|
||||
)
|
||||
if new_value != str(value):
|
||||
# Try to maintain type
|
||||
try:
|
||||
if isinstance(value, bool):
|
||||
parameters[key] = new_value.lower() in ("true", "yes", "1", "on")
|
||||
elif isinstance(value, int):
|
||||
parameters[key] = int(new_value)
|
||||
elif isinstance(value, float):
|
||||
parameters[key] = float(new_value)
|
||||
elif isinstance(value, list):
|
||||
parameters[key] = [item.strip() for item in new_value.split(",") if item.strip()]
|
||||
else:
|
||||
parameters[key] = new_value
|
||||
except ValueError:
|
||||
parameters[key] = new_value
|
||||
|
||||
# Submit new execution
|
||||
with get_client() as client:
|
||||
submission = WorkflowSubmission(
|
||||
target_path=original_run.target_path,
|
||||
parameters=parameters
|
||||
)
|
||||
|
||||
response = client.submit_workflow(original_run.workflow, submission)
|
||||
|
||||
console.print(f"\n✅ Retry submitted successfully!", style="green")
|
||||
console.print(f" New Execution ID: [bold cyan]{response.run_id}[/bold cyan]")
|
||||
console.print(f" Status: {status_emoji(response.status)} {response.status}")
|
||||
|
||||
# Save to database
|
||||
try:
|
||||
run_record = RunRecord(
|
||||
run_id=response.run_id,
|
||||
workflow=original_run.workflow,
|
||||
status=response.status,
|
||||
target_path=original_run.target_path,
|
||||
parameters=parameters,
|
||||
created_at=datetime.now(),
|
||||
metadata={"retry_of": execution_id}
|
||||
)
|
||||
db.save_run(run_record)
|
||||
except Exception as e:
|
||||
console.print(f"⚠️ Failed to save execution to database: {e}", style="yellow")
|
||||
|
||||
console.print(f"\n💡 Monitor progress: [bold cyan]fuzzforge monitor {response.run_id}[/bold cyan]")
|
||||
|
||||
except Exception as e:
|
||||
handle_error(e, "retrying workflow")
|
||||
|
||||
|
||||
@app.callback()
|
||||
def workflow_exec_callback():
|
||||
"""
|
||||
🚀 Workflow execution management
|
||||
"""
|
||||
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
Workflow 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 typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.panel import Panel
|
||||
from rich.prompt import Prompt, Confirm
|
||||
from rich.syntax import Syntax
|
||||
from rich import box
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from ..config import get_project_config, FuzzForgeConfig
|
||||
from ..fuzzy import enhanced_workflow_not_found_handler
|
||||
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())
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def list_workflows():
|
||||
"""
|
||||
📋 List all available security testing workflows
|
||||
"""
|
||||
try:
|
||||
with get_client() as client:
|
||||
workflows = client.list_workflows()
|
||||
|
||||
if not workflows:
|
||||
console.print("❌ No workflows available", style="red")
|
||||
return
|
||||
|
||||
table = Table(box=box.ROUNDED)
|
||||
table.add_column("Name", style="bold cyan")
|
||||
table.add_column("Version", justify="center")
|
||||
table.add_column("Description")
|
||||
table.add_column("Tags", style="dim")
|
||||
|
||||
for workflow in workflows:
|
||||
tags_str = ", ".join(workflow.tags) if workflow.tags else ""
|
||||
table.add_row(
|
||||
workflow.name,
|
||||
workflow.version,
|
||||
workflow.description,
|
||||
tags_str
|
||||
)
|
||||
|
||||
console.print(f"\n🔧 [bold]Available Workflows ({len(workflows)})[/bold]\n")
|
||||
console.print(table)
|
||||
|
||||
console.print(f"\n💡 Use [bold cyan]fuzzforge workflows info <name>[/bold cyan] for detailed information")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"❌ Failed to fetch workflows: {e}", style="red")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command("info")
|
||||
def workflow_info(
|
||||
name: str = typer.Argument(..., help="Workflow name to get information about")
|
||||
):
|
||||
"""
|
||||
📋 Show detailed information about a specific workflow
|
||||
"""
|
||||
try:
|
||||
with get_client() as client:
|
||||
workflow = client.get_workflow_metadata(name)
|
||||
|
||||
console.print(f"\n🔧 [bold]Workflow: {workflow.name}[/bold]\n")
|
||||
|
||||
# Basic information
|
||||
info_table = Table(show_header=False, box=box.SIMPLE)
|
||||
info_table.add_column("Property", style="bold cyan")
|
||||
info_table.add_column("Value")
|
||||
|
||||
info_table.add_row("Name", workflow.name)
|
||||
info_table.add_row("Version", workflow.version)
|
||||
info_table.add_row("Description", workflow.description)
|
||||
if workflow.author:
|
||||
info_table.add_row("Author", workflow.author)
|
||||
if workflow.tags:
|
||||
info_table.add_row("Tags", ", ".join(workflow.tags))
|
||||
info_table.add_row("Volume Modes", ", ".join(workflow.supported_volume_modes))
|
||||
info_table.add_row("Custom Docker", "✅ Yes" if workflow.has_custom_docker else "❌ No")
|
||||
|
||||
console.print(
|
||||
Panel.fit(
|
||||
info_table,
|
||||
title="ℹ️ Basic Information",
|
||||
box=box.ROUNDED
|
||||
)
|
||||
)
|
||||
|
||||
# Parameters
|
||||
if workflow.parameters:
|
||||
console.print("\n📝 [bold]Parameters Schema[/bold]")
|
||||
|
||||
param_table = Table(box=box.ROUNDED)
|
||||
param_table.add_column("Parameter", style="bold")
|
||||
param_table.add_column("Type", style="cyan")
|
||||
param_table.add_column("Required", justify="center")
|
||||
param_table.add_column("Default")
|
||||
param_table.add_column("Description", style="dim")
|
||||
|
||||
# Extract parameter information from JSON schema
|
||||
properties = workflow.parameters.get("properties", {})
|
||||
required_params = set(workflow.parameters.get("required", []))
|
||||
defaults = workflow.default_parameters
|
||||
|
||||
for param_name, param_schema in properties.items():
|
||||
param_type = param_schema.get("type", "unknown")
|
||||
is_required = "✅" if param_name in required_params else "❌"
|
||||
default_val = str(defaults.get(param_name, "")) if param_name in defaults else ""
|
||||
description = param_schema.get("description", "")
|
||||
|
||||
# Handle array types
|
||||
if param_type == "array":
|
||||
items_type = param_schema.get("items", {}).get("type", "unknown")
|
||||
param_type = f"array[{items_type}]"
|
||||
|
||||
param_table.add_row(
|
||||
param_name,
|
||||
param_type,
|
||||
is_required,
|
||||
default_val[:30] + "..." if len(default_val) > 30 else default_val,
|
||||
description[:50] + "..." if len(description) > 50 else description
|
||||
)
|
||||
|
||||
console.print(param_table)
|
||||
|
||||
# Required modules
|
||||
if workflow.required_modules:
|
||||
console.print(f"\n🔧 [bold]Required Modules:[/bold] {', '.join(workflow.required_modules)}")
|
||||
|
||||
console.print(f"\n💡 Use [bold cyan]fuzzforge workflows parameters {name}[/bold cyan] for interactive parameter builder")
|
||||
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
if "not found" in error_message.lower() or "404" in error_message:
|
||||
# Try fuzzy matching for workflow name
|
||||
enhanced_workflow_not_found_handler(name)
|
||||
else:
|
||||
console.print(f"❌ Failed to get workflow info: {e}", style="red")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command("parameters")
|
||||
def workflow_parameters(
|
||||
name: str = typer.Argument(..., help="Workflow name"),
|
||||
output_file: Optional[str] = typer.Option(
|
||||
None, "--output", "-o",
|
||||
help="Save parameters to JSON file"
|
||||
),
|
||||
interactive: bool = typer.Option(
|
||||
True, "--interactive/--no-interactive", "-i/-n",
|
||||
help="Interactive parameter builder"
|
||||
)
|
||||
):
|
||||
"""
|
||||
📝 Interactive parameter builder for workflows
|
||||
"""
|
||||
try:
|
||||
with get_client() as client:
|
||||
workflow = client.get_workflow_metadata(name)
|
||||
param_response = client.get_workflow_parameters(name)
|
||||
|
||||
console.print(f"\n📝 [bold]Parameter Builder: {name}[/bold]\n")
|
||||
|
||||
if not workflow.parameters.get("properties"):
|
||||
console.print("ℹ️ This workflow has no configurable parameters")
|
||||
return
|
||||
|
||||
parameters = {}
|
||||
properties = workflow.parameters.get("properties", {})
|
||||
required_params = set(workflow.parameters.get("required", []))
|
||||
defaults = param_response.defaults
|
||||
|
||||
if interactive:
|
||||
console.print("🔧 Enter parameter values (press Enter for default):\n")
|
||||
|
||||
for param_name, param_schema in properties.items():
|
||||
param_type = param_schema.get("type", "string")
|
||||
description = param_schema.get("description", "")
|
||||
is_required = param_name in required_params
|
||||
default_value = defaults.get(param_name)
|
||||
|
||||
# Build prompt
|
||||
prompt_text = f"{param_name}"
|
||||
if description:
|
||||
prompt_text += f" ({description})"
|
||||
if param_type:
|
||||
prompt_text += f" [{param_type}]"
|
||||
if is_required:
|
||||
prompt_text += " [bold red]*required*[/bold red]"
|
||||
|
||||
# Get user input
|
||||
while True:
|
||||
if default_value is not None:
|
||||
user_input = Prompt.ask(
|
||||
prompt_text,
|
||||
default=str(default_value),
|
||||
console=console
|
||||
)
|
||||
else:
|
||||
user_input = Prompt.ask(
|
||||
prompt_text,
|
||||
console=console
|
||||
)
|
||||
|
||||
# Validate and convert input
|
||||
if user_input.strip() == "" and not is_required:
|
||||
break
|
||||
|
||||
if user_input.strip() == "" and is_required:
|
||||
console.print("❌ This parameter is required", style="red")
|
||||
continue
|
||||
|
||||
try:
|
||||
# Type conversion
|
||||
if param_type == "integer":
|
||||
parameters[param_name] = int(user_input)
|
||||
elif param_type == "number":
|
||||
parameters[param_name] = float(user_input)
|
||||
elif param_type == "boolean":
|
||||
parameters[param_name] = user_input.lower() in ("true", "yes", "1", "on")
|
||||
elif param_type == "array":
|
||||
# Simple comma-separated array
|
||||
parameters[param_name] = [item.strip() for item in user_input.split(",") if item.strip()]
|
||||
else:
|
||||
parameters[param_name] = user_input
|
||||
|
||||
break
|
||||
|
||||
except ValueError as e:
|
||||
console.print(f"❌ Invalid {param_type}: {e}", style="red")
|
||||
|
||||
# Show summary
|
||||
console.print("\n📋 [bold]Parameter Summary:[/bold]")
|
||||
summary_table = Table(show_header=False, box=box.SIMPLE)
|
||||
summary_table.add_column("Parameter", style="cyan")
|
||||
summary_table.add_column("Value", style="white")
|
||||
|
||||
for key, value in parameters.items():
|
||||
summary_table.add_row(key, str(value))
|
||||
|
||||
console.print(summary_table)
|
||||
|
||||
else:
|
||||
# Non-interactive mode - show schema
|
||||
console.print("📋 Parameter Schema:")
|
||||
schema_json = json.dumps(workflow.parameters, indent=2)
|
||||
console.print(Syntax(schema_json, "json", theme="monokai"))
|
||||
|
||||
if defaults:
|
||||
console.print("\n📋 Default Values:")
|
||||
defaults_json = json.dumps(defaults, indent=2)
|
||||
console.print(Syntax(defaults_json, "json", theme="monokai"))
|
||||
|
||||
# Save to file if requested
|
||||
if output_file:
|
||||
if parameters or not interactive:
|
||||
data_to_save = parameters if interactive else {"schema": workflow.parameters, "defaults": defaults}
|
||||
with open(output_file, 'w') as f:
|
||||
json.dump(data_to_save, f, indent=2)
|
||||
console.print(f"\n💾 Parameters saved to: {output_file}")
|
||||
else:
|
||||
console.print("\n❌ No parameters to save", style="red")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"❌ Failed to build parameters: {e}", style="red")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def workflows_callback(ctx: typer.Context):
|
||||
"""
|
||||
🔧 Manage security testing workflows
|
||||
"""
|
||||
# Check if a subcommand is being invoked
|
||||
if ctx.invoked_subcommand is not None:
|
||||
# Let the subcommand handle it
|
||||
return
|
||||
|
||||
# Default to list when no subcommand provided
|
||||
list_workflows()
|
||||
Reference in New Issue
Block a user