Initial commit

This commit is contained in:
Tanguy Duhamel
2025-09-29 21:26:41 +02:00
parent f0fd367ed8
commit 323a434c73
208 changed files with 72069 additions and 53 deletions
@@ -0,0 +1,14 @@
"""
Command modules for FuzzForge CLI.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
+133
View File
@@ -0,0 +1,133 @@
"""AI integration commands for the FuzzForge CLI."""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
from __future__ import annotations
import asyncio
import os
from datetime import datetime
from typing import Optional
import typer
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from ..config import ProjectConfigManager
console = Console()
app = typer.Typer(name="ai", help="Interact with the FuzzForge AI system")
@app.command("agent")
def ai_agent() -> None:
"""Launch the full AI agent CLI with A2A orchestration."""
console.print("[cyan]🤖 Opening Project FuzzForge AI Agent session[/cyan]\n")
try:
from fuzzforge_ai.cli import FuzzForgeCLI
cli = FuzzForgeCLI()
asyncio.run(cli.run())
except ImportError as exc:
console.print(f"[red]Failed to import AI CLI:[/red] {exc}")
console.print("[dim]Ensure AI dependencies are installed (pip install -e .)[/dim]")
raise typer.Exit(1) from exc
except Exception as exc: # pragma: no cover - runtime safety
console.print(f"[red]Failed to launch AI agent:[/red] {exc}")
console.print("[dim]Check that .env contains LITELLM_MODEL and API keys[/dim]")
raise typer.Exit(1) from exc
# Memory + health commands
@app.command("status")
def ai_status() -> None:
"""Show AI system health and configuration."""
try:
status = asyncio.run(get_ai_status_async())
except Exception as exc: # pragma: no cover
console.print(f"[red]Failed to get AI status:[/red] {exc}")
raise typer.Exit(1) from exc
console.print("[bold cyan]🤖 FuzzForge AI System Status[/bold cyan]\n")
config_table = Table(title="Configuration", show_header=True, header_style="bold magenta")
config_table.add_column("Setting", style="bold")
config_table.add_column("Value", style="cyan")
config_table.add_column("Status", style="green")
for key, info in status["config"].items():
status_icon = "" if info["configured"] else ""
display_value = info["value"] if info["value"] else "-"
config_table.add_row(key, display_value, f"{status_icon}")
console.print(config_table)
console.print()
components_table = Table(title="AI Components", show_header=True, header_style="bold magenta")
components_table.add_column("Component", style="bold")
components_table.add_column("Status", style="green")
components_table.add_column("Details", style="dim")
for component, info in status["components"].items():
status_icon = "🟢" if info["available"] else "🔴"
components_table.add_row(component, status_icon, info["details"])
console.print(components_table)
if status["agents"]:
console.print()
console.print(f"[bold green]✓[/bold green] {len(status['agents'])} agents registered")
@app.command("server")
def ai_server(
port: int = typer.Option(10100, "--port", "-p", help="Server port (default: 10100)"),
) -> None:
"""Start AI system as an A2A server."""
console.print(f"[cyan]🚀 Starting FuzzForge AI Server on port {port}[/cyan]")
console.print("[dim]Other agents can register this instance at the A2A endpoint[/dim]\n")
try:
os.environ["FUZZFORGE_PORT"] = str(port)
from fuzzforge_ai.__main__ import main as start_server
start_server()
except Exception as exc: # pragma: no cover
console.print(f"[red]Failed to start AI server:[/red] {exc}")
raise typer.Exit(1) from exc
# ---------------------------------------------------------------------------
# Helper functions (largely adapted from the OSS implementation)
# ---------------------------------------------------------------------------
@app.callback(invoke_without_command=True)
def ai_callback(ctx: typer.Context):
"""
🤖 AI integration features
"""
# Check if a subcommand is being invoked
if ctx.invoked_subcommand is not None:
# Let the subcommand handle it
return
# Show not implemented message for default command
console.print("🚧 [yellow]AI command is not fully implemented yet.[/yellow]")
console.print("Please use specific subcommands:")
console.print(" • [cyan]ff ai agent[/cyan] - Launch the full AI agent CLI")
console.print(" • [cyan]ff ai status[/cyan] - Show AI system health and configuration")
console.print(" • [cyan]ff ai server[/cyan] - Start AI system as an A2A server")
+384
View File
@@ -0,0 +1,384 @@
"""
Configuration management commands.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import typer
from pathlib import Path
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.prompt import Prompt, Confirm
from rich import box
from typing import Optional
from ..config import (
get_project_config,
ensure_project_config,
get_global_config,
save_global_config,
FuzzForgeConfig
)
from ..exceptions import require_project, ValidationError, handle_error
console = Console()
app = typer.Typer()
@app.command("show")
def show_config(
global_config: bool = typer.Option(
False, "--global", "-g",
help="Show global configuration instead of project config"
)
):
"""
📋 Display current configuration settings
"""
if global_config:
config = get_global_config()
config_type = "Global"
config_path = Path.home() / ".config" / "fuzzforge" / "config.yaml"
else:
try:
require_project()
config = get_project_config()
if not config:
raise ValidationError("project configuration", "missing", "initialized project")
except Exception as e:
handle_error(e, "loading project configuration")
return # Unreachable, but makes static analysis happy
config_type = "Project"
config_path = Path.cwd() / ".fuzzforge" / "config.yaml"
console.print(f"\n⚙️ [bold]{config_type} Configuration[/bold]\n")
# Project settings
project_table = Table(show_header=False, box=box.SIMPLE)
project_table.add_column("Setting", style="bold cyan")
project_table.add_column("Value")
project_table.add_row("Project Name", config.project.name)
project_table.add_row("API URL", config.project.api_url)
project_table.add_row("Default Timeout", f"{config.project.default_timeout}s")
if config.project.default_workflow:
project_table.add_row("Default Workflow", config.project.default_workflow)
console.print(
Panel.fit(
project_table,
title="📁 Project Settings",
box=box.ROUNDED
)
)
# Retention settings
retention_table = Table(show_header=False, box=box.SIMPLE)
retention_table.add_column("Setting", style="bold cyan")
retention_table.add_column("Value")
retention_table.add_row("Max Runs", str(config.retention.max_runs))
retention_table.add_row("Keep Findings (days)", str(config.retention.keep_findings_days))
console.print(
Panel.fit(
retention_table,
title="🗄️ Data Retention",
box=box.ROUNDED
)
)
# Preferences
prefs_table = Table(show_header=False, box=box.SIMPLE)
prefs_table.add_column("Setting", style="bold cyan")
prefs_table.add_column("Value")
prefs_table.add_row("Auto Save Findings", "✅ Yes" if config.preferences.auto_save_findings else "❌ No")
prefs_table.add_row("Show Progress Bars", "✅ Yes" if config.preferences.show_progress_bars else "❌ No")
prefs_table.add_row("Table Style", config.preferences.table_style)
prefs_table.add_row("Color Output", "✅ Yes" if config.preferences.color_output else "❌ No")
console.print(
Panel.fit(
prefs_table,
title="🎨 Preferences",
box=box.ROUNDED
)
)
console.print(f"\n📍 Config file: [dim]{config_path}[/dim]")
@app.command("set")
def set_config(
key: str = typer.Argument(..., help="Configuration key to set (e.g., 'project.name', 'project.api_url')"),
value: str = typer.Argument(..., help="Value to set"),
global_config: bool = typer.Option(
False, "--global", "-g",
help="Set in global configuration instead of project config"
)
):
"""
⚙️ Set a configuration value
"""
if global_config:
config = get_global_config()
config_type = "global"
else:
config = get_project_config()
if not config:
console.print("❌ No project configuration found. Run 'ff init' first.", style="red")
raise typer.Exit(1)
config_type = "project"
# Parse the key path
key_parts = key.split('.')
if len(key_parts) != 2:
console.print("❌ Key must be in format 'section.setting' (e.g., 'project.name')", style="red")
raise typer.Exit(1)
section, setting = key_parts
try:
# Update configuration
if section == "project":
if setting == "name":
config.project.name = value
elif setting == "api_url":
config.project.api_url = value
elif setting == "default_timeout":
config.project.default_timeout = int(value)
elif setting == "default_workflow":
config.project.default_workflow = value if value.lower() != "none" else None
else:
console.print(f"❌ Unknown project setting: {setting}", style="red")
raise typer.Exit(1)
elif section == "retention":
if setting == "max_runs":
config.retention.max_runs = int(value)
elif setting == "keep_findings_days":
config.retention.keep_findings_days = int(value)
else:
console.print(f"❌ Unknown retention setting: {setting}", style="red")
raise typer.Exit(1)
elif section == "preferences":
if setting == "auto_save_findings":
config.preferences.auto_save_findings = value.lower() in ("true", "yes", "1", "on")
elif setting == "show_progress_bars":
config.preferences.show_progress_bars = value.lower() in ("true", "yes", "1", "on")
elif setting == "table_style":
config.preferences.table_style = value
elif setting == "color_output":
config.preferences.color_output = value.lower() in ("true", "yes", "1", "on")
else:
console.print(f"❌ Unknown preferences setting: {setting}", style="red")
raise typer.Exit(1)
else:
console.print(f"❌ Unknown configuration section: {section}", style="red")
console.print("Valid sections: project, retention, preferences", style="dim")
raise typer.Exit(1)
# Save configuration
if global_config:
save_global_config(config)
else:
config_path = Path.cwd() / ".fuzzforge" / "config.yaml"
config.save_to_file(config_path)
console.print(f"✅ Set {config_type} configuration: [bold cyan]{key}[/bold cyan] = [bold]{value}[/bold]", style="green")
except ValueError as e:
console.print(f"❌ Invalid value for {key}: {e}", style="red")
raise typer.Exit(1)
except Exception as e:
console.print(f"❌ Failed to set configuration: {e}", style="red")
raise typer.Exit(1)
@app.command("get")
def get_config(
key: str = typer.Argument(..., help="Configuration key to get (e.g., 'project.name')"),
global_config: bool = typer.Option(
False, "--global", "-g",
help="Get from global configuration instead of project config"
)
):
"""
📖 Get a specific configuration value
"""
if global_config:
config = get_global_config()
else:
config = get_project_config()
if not config:
console.print("❌ No project configuration found. Run 'ff init' first.", style="red")
raise typer.Exit(1)
# Parse the key path
key_parts = key.split('.')
if len(key_parts) != 2:
console.print("❌ Key must be in format 'section.setting' (e.g., 'project.name')", style="red")
raise typer.Exit(1)
section, setting = key_parts
try:
# Get configuration value
if section == "project":
if setting == "name":
value = config.project.name
elif setting == "api_url":
value = config.project.api_url
elif setting == "default_timeout":
value = config.project.default_timeout
elif setting == "default_workflow":
value = config.project.default_workflow or "none"
else:
console.print(f"❌ Unknown project setting: {setting}", style="red")
raise typer.Exit(1)
elif section == "retention":
if setting == "max_runs":
value = config.retention.max_runs
elif setting == "keep_findings_days":
value = config.retention.keep_findings_days
else:
console.print(f"❌ Unknown retention setting: {setting}", style="red")
raise typer.Exit(1)
elif section == "preferences":
if setting == "auto_save_findings":
value = config.preferences.auto_save_findings
elif setting == "show_progress_bars":
value = config.preferences.show_progress_bars
elif setting == "table_style":
value = config.preferences.table_style
elif setting == "color_output":
value = config.preferences.color_output
else:
console.print(f"❌ Unknown preferences setting: {setting}", style="red")
raise typer.Exit(1)
else:
console.print(f"❌ Unknown configuration section: {section}", style="red")
raise typer.Exit(1)
console.print(f"{key}: [bold cyan]{value}[/bold cyan]")
except Exception as e:
console.print(f"❌ Failed to get configuration: {e}", style="red")
raise typer.Exit(1)
@app.command("reset")
def reset_config(
global_config: bool = typer.Option(
False, "--global", "-g",
help="Reset global configuration instead of project config"
),
force: bool = typer.Option(
False, "--force", "-f",
help="Skip confirmation prompt"
)
):
"""
🔄 Reset configuration to defaults
"""
config_type = "global" if global_config else "project"
if not force:
if not Confirm.ask(f"Reset {config_type} configuration to defaults?", default=False, console=console):
console.print("❌ Reset cancelled", style="yellow")
raise typer.Exit(0)
try:
# Create new default configuration
new_config = FuzzForgeConfig()
if global_config:
save_global_config(new_config)
else:
if not Path.cwd().joinpath(".fuzzforge").exists():
console.print("❌ No project configuration found. Run 'ff init' first.", style="red")
raise typer.Exit(1)
config_path = Path.cwd() / ".fuzzforge" / "config.yaml"
new_config.save_to_file(config_path)
console.print(f"{config_type.title()} configuration reset to defaults", style="green")
except Exception as e:
console.print(f"❌ Failed to reset configuration: {e}", style="red")
raise typer.Exit(1)
@app.command("edit")
def edit_config(
global_config: bool = typer.Option(
False, "--global", "-g",
help="Edit global configuration instead of project config"
)
):
"""
📝 Open configuration file in default editor
"""
import os
import subprocess
if global_config:
config_path = Path.home() / ".config" / "fuzzforge" / "config.yaml"
config_type = "global"
else:
config_path = Path.cwd() / ".fuzzforge" / "config.yaml"
config_type = "project"
if not config_path.exists():
console.print("❌ No project configuration found. Run 'ff init' first.", style="red")
raise typer.Exit(1)
# Try to find a suitable editor
editors = ["code", "vim", "nano", "notepad"]
editor = None
for e in editors:
try:
subprocess.run([e, "--version"], capture_output=True, check=True)
editor = e
break
except (subprocess.CalledProcessError, FileNotFoundError):
continue
if not editor:
console.print(f"📍 Configuration file: [bold cyan]{config_path}[/bold cyan]")
console.print("❌ No suitable editor found. Please edit the file manually.", style="red")
raise typer.Exit(1)
try:
console.print(f"📝 Opening {config_type} configuration in {editor}...")
subprocess.run([editor, str(config_path)], check=True)
console.print(f"✅ Configuration file edited", style="green")
except subprocess.CalledProcessError as e:
console.print(f"❌ Failed to open editor: {e}", style="red")
raise typer.Exit(1)
@app.callback()
def config_callback():
"""
⚙️ Manage configuration settings
"""
pass
+940
View File
@@ -0,0 +1,940 @@
"""
Findings and security results management commands.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import json
import csv
from datetime import datetime
from pathlib import Path
from typing import Optional, Dict, Any, List
import typer
from rich.console import Console
from rich.table import Table, Column
from rich.panel import Panel
from rich.syntax import Syntax
from rich.tree import Tree
from rich.text import Text
from rich import box
from ..config import get_project_config, FuzzForgeConfig
from ..database import get_project_db, ensure_project_db, FindingRecord
from ..exceptions import (
handle_error, retry_on_network_error, validate_run_id,
require_project, ValidationError, DatabaseError
)
from fuzzforge_sdk import FuzzForgeClient
console = Console()
app = typer.Typer()
@retry_on_network_error(max_retries=3, delay=1.0)
def get_client() -> FuzzForgeClient:
"""Get configured FuzzForge client with retry on network errors"""
config = get_project_config() or FuzzForgeConfig()
return FuzzForgeClient(base_url=config.get_api_url(), timeout=config.get_timeout())
def severity_style(severity: str) -> str:
"""Get rich style for severity level"""
return {
"error": "bold red",
"warning": "bold yellow",
"note": "bold blue",
"info": "bold cyan"
}.get(severity.lower(), "white")
@app.command("get")
def get_findings(
run_id: str = typer.Argument(..., help="Run ID to get findings for"),
save: bool = typer.Option(
True, "--save/--no-save",
help="Save findings to local database"
),
format: str = typer.Option(
"table", "--format", "-f",
help="Output format: table, json, sarif"
)
):
"""
🔍 Retrieve and display security findings for a run
"""
try:
require_project()
validate_run_id(run_id)
if format not in ["table", "json", "sarif"]:
raise ValidationError("format", format, "one of: table, json, sarif")
with get_client() as client:
console.print(f"🔍 Fetching findings for run: {run_id}")
findings = client.get_run_findings(run_id)
# Save to database if requested
if save:
try:
db = ensure_project_db()
# Extract summary from SARIF
sarif_data = findings.sarif
runs_data = sarif_data.get("runs", [])
summary = {}
if runs_data:
results = runs_data[0].get("results", [])
summary = {
"total_issues": len(results),
"by_severity": {},
"by_rule": {},
"tools": []
}
for result in results:
level = result.get("level", "note")
rule_id = result.get("ruleId", "unknown")
summary["by_severity"][level] = summary["by_severity"].get(level, 0) + 1
summary["by_rule"][rule_id] = summary["by_rule"].get(rule_id, 0) + 1
# Extract tool info
tool = runs_data[0].get("tool", {})
driver = tool.get("driver", {})
if driver.get("name"):
summary["tools"].append({
"name": driver.get("name"),
"version": driver.get("version"),
"rules": len(driver.get("rules", []))
})
finding_record = FindingRecord(
run_id=run_id,
sarif_data=sarif_data,
summary=summary,
created_at=datetime.now()
)
db.save_findings(finding_record)
console.print("✅ Findings saved to local database", style="green")
except Exception as e:
console.print(f"⚠️ Failed to save findings to database: {e}", style="yellow")
# Display findings
if format == "json":
findings_json = json.dumps(findings.sarif, indent=2)
console.print(Syntax(findings_json, "json", theme="monokai"))
elif format == "sarif":
sarif_json = json.dumps(findings.sarif, indent=2)
console.print(sarif_json)
else: # table format
display_findings_table(findings.sarif)
except Exception as e:
console.print(f"❌ Failed to get findings: {e}", style="red")
raise typer.Exit(1)
def display_findings_table(sarif_data: Dict[str, Any]):
"""Display SARIF findings in a rich table format"""
runs = sarif_data.get("runs", [])
if not runs:
console.print("️ No findings data available", style="dim")
return
run_data = runs[0]
results = run_data.get("results", [])
tool = run_data.get("tool", {})
driver = tool.get("driver", {})
# Tool information
console.print(f"\n🔍 [bold]Security Analysis Results[/bold]")
if driver.get("name"):
console.print(f"Tool: {driver.get('name')} v{driver.get('version', 'unknown')}")
if not results:
console.print("✅ No security issues found!", style="green")
return
# Summary statistics
summary_by_level = {}
for result in results:
level = result.get("level", "note")
summary_by_level[level] = summary_by_level.get(level, 0) + 1
summary_table = Table(show_header=False, box=box.SIMPLE)
summary_table.add_column("Severity", width=15, justify="left", style="bold")
summary_table.add_column("Count", width=8, justify="right", style="bold")
for level, count in sorted(summary_by_level.items()):
# Create Rich Text object with color styling
level_text = level.upper()
severity_text = Text(level_text, style=severity_style(level))
count_text = Text(str(count))
summary_table.add_row(severity_text, count_text)
console.print(
Panel.fit(
summary_table,
title=f"📊 Summary ({len(results)} total issues)",
box=box.ROUNDED
)
)
# Detailed results - Rich Text-based table with proper emoji alignment
results_table = Table(box=box.ROUNDED)
results_table.add_column("Severity", width=12, justify="left", no_wrap=True)
results_table.add_column("Rule", width=25, justify="left", style="bold cyan", no_wrap=True)
results_table.add_column("Message", width=55, justify="left", no_wrap=True)
results_table.add_column("Location", width=20, justify="left", style="dim", no_wrap=True)
for result in results[:50]: # Limit to first 50 results
level = result.get("level", "note")
rule_id = result.get("ruleId", "unknown")
message = result.get("message", {}).get("text", "No message")
# Extract location information
locations = result.get("locations", [])
location_str = ""
if locations:
physical_location = locations[0].get("physicalLocation", {})
artifact_location = physical_location.get("artifactLocation", {})
region = physical_location.get("region", {})
file_path = artifact_location.get("uri", "")
if file_path:
location_str = Path(file_path).name
if region.get("startLine"):
location_str += f":{region['startLine']}"
if region.get("startColumn"):
location_str += f":{region['startColumn']}"
# Create Rich Text objects with color styling
severity_text = Text(level.upper(), style=severity_style(level))
severity_text.truncate(12, overflow="ellipsis")
rule_text = Text(rule_id)
rule_text.truncate(25, overflow="ellipsis")
message_text = Text(message)
message_text.truncate(55, overflow="ellipsis")
location_text = Text(location_str)
location_text.truncate(20, overflow="ellipsis")
results_table.add_row(
severity_text,
rule_text,
message_text,
location_text
)
console.print(f"\n📋 [bold]Detailed Results[/bold]")
if len(results) > 50:
console.print(f"Showing first 50 of {len(results)} results")
console.print()
console.print(results_table)
@app.command("history")
def findings_history(
limit: int = typer.Option(20, "--limit", "-l", help="Maximum number of findings to show")
):
"""
📚 Show findings history from local database
"""
db = get_project_db()
if not db:
console.print("❌ No FuzzForge project found. Run 'ff init' first.", style="red")
raise typer.Exit(1)
try:
findings = db.list_findings(limit=limit)
if not findings:
console.print("❌ No findings found in database", style="red")
return
table = Table(box=box.ROUNDED)
table.add_column("Run ID", style="bold cyan", width=36) # Full UUID width
table.add_column("Date", justify="center")
table.add_column("Total Issues", justify="center", style="bold")
table.add_column("Errors", justify="center", style="red")
table.add_column("Warnings", justify="center", style="yellow")
table.add_column("Notes", justify="center", style="blue")
table.add_column("Tools", style="dim")
for finding in findings:
summary = finding.summary
total_issues = summary.get("total_issues", 0)
by_severity = summary.get("by_severity", {})
tools = summary.get("tools", [])
tool_names = ", ".join([tool.get("name", "Unknown") for tool in tools])
table.add_row(
finding.run_id, # Show full Run ID
finding.created_at.strftime("%m-%d %H:%M"),
str(total_issues),
str(by_severity.get("error", 0)),
str(by_severity.get("warning", 0)),
str(by_severity.get("note", 0)),
tool_names[:30] + "..." if len(tool_names) > 30 else tool_names
)
console.print(f"\n📚 [bold]Findings History ({len(findings)})[/bold]\n")
console.print(table)
console.print(f"\n💡 Use [bold cyan]fuzzforge finding <run-id>[/bold cyan] to view detailed findings")
except Exception as e:
console.print(f"❌ Failed to get findings history: {e}", style="red")
raise typer.Exit(1)
@app.command("export")
def export_findings(
run_id: str = typer.Argument(..., help="Run ID to export findings for"),
format: str = typer.Option(
"json", "--format", "-f",
help="Export format: json, csv, html, sarif"
),
output: Optional[str] = typer.Option(
None, "--output", "-o",
help="Output file path (defaults to findings-<run-id>.<format>)"
)
):
"""
📤 Export security findings in various formats
"""
db = get_project_db()
if not db:
console.print("❌ No FuzzForge project found. Run 'ff init' first.", style="red")
raise typer.Exit(1)
try:
# Get findings from database first, fallback to API
findings_data = db.get_findings(run_id)
if not findings_data:
console.print(f"📡 Fetching findings from API for run: {run_id}")
with get_client() as client:
findings = client.get_run_findings(run_id)
sarif_data = findings.sarif
else:
sarif_data = findings_data.sarif_data
# Generate output filename
if not output:
output = f"findings-{run_id[:8]}.{format}"
output_path = Path(output)
# Export based on format
if format == "sarif":
with open(output_path, 'w') as f:
json.dump(sarif_data, f, indent=2)
elif format == "json":
# Simplified JSON format
simplified_data = extract_simplified_findings(sarif_data)
with open(output_path, 'w') as f:
json.dump(simplified_data, f, indent=2)
elif format == "csv":
export_to_csv(sarif_data, output_path)
elif format == "html":
export_to_html(sarif_data, output_path, run_id)
else:
console.print(f"❌ Unsupported format: {format}", style="red")
raise typer.Exit(1)
console.print(f"✅ Findings exported to: [bold cyan]{output_path}[/bold cyan]")
except Exception as e:
console.print(f"❌ Failed to export findings: {e}", style="red")
raise typer.Exit(1)
def extract_simplified_findings(sarif_data: Dict[str, Any]) -> Dict[str, Any]:
"""Extract simplified findings structure from SARIF"""
runs = sarif_data.get("runs", [])
if not runs:
return {"findings": [], "summary": {}}
run_data = runs[0]
results = run_data.get("results", [])
tool = run_data.get("tool", {}).get("driver", {})
simplified = {
"tool": {
"name": tool.get("name", "Unknown"),
"version": tool.get("version", "Unknown")
},
"summary": {
"total_issues": len(results),
"by_severity": {}
},
"findings": []
}
for result in results:
level = result.get("level", "note")
simplified["summary"]["by_severity"][level] = simplified["summary"]["by_severity"].get(level, 0) + 1
# Extract location
location_info = {}
locations = result.get("locations", [])
if locations:
physical_location = locations[0].get("physicalLocation", {})
artifact_location = physical_location.get("artifactLocation", {})
region = physical_location.get("region", {})
location_info = {
"file": artifact_location.get("uri", ""),
"line": region.get("startLine"),
"column": region.get("startColumn")
}
simplified["findings"].append({
"rule_id": result.get("ruleId", "unknown"),
"severity": level,
"message": result.get("message", {}).get("text", ""),
"location": location_info
})
return simplified
def export_to_csv(sarif_data: Dict[str, Any], output_path: Path):
"""Export findings to CSV format"""
runs = sarif_data.get("runs", [])
if not runs:
return
results = runs[0].get("results", [])
with open(output_path, 'w', newline='', encoding='utf-8') as csvfile:
fieldnames = ['rule_id', 'severity', 'message', 'file', 'line', 'column']
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writeheader()
for result in results:
location_info = {"file": "", "line": "", "column": ""}
locations = result.get("locations", [])
if locations:
physical_location = locations[0].get("physicalLocation", {})
artifact_location = physical_location.get("artifactLocation", {})
region = physical_location.get("region", {})
location_info = {
"file": artifact_location.get("uri", ""),
"line": region.get("startLine", ""),
"column": region.get("startColumn", "")
}
writer.writerow({
"rule_id": result.get("ruleId", ""),
"severity": result.get("level", "note"),
"message": result.get("message", {}).get("text", ""),
**location_info
})
def export_to_html(sarif_data: Dict[str, Any], output_path: Path, run_id: str):
"""Export findings to HTML format"""
runs = sarif_data.get("runs", [])
if not runs:
return
run_data = runs[0]
results = run_data.get("results", [])
tool = run_data.get("tool", {}).get("driver", {})
# Simple HTML template
html_content = f"""<!DOCTYPE html>
<html>
<head>
<title>Security Findings - {run_id}</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 40px; }}
.header {{ background: #f4f4f4; padding: 20px; border-radius: 5px; }}
.summary {{ margin: 20px 0; }}
.findings {{ margin: 20px 0; }}
table {{ width: 100%; border-collapse: collapse; }}
th, td {{ padding: 10px; text-align: left; border-bottom: 1px solid #ddd; }}
th {{ background-color: #f2f2f2; }}
.error {{ color: #d32f2f; }}
.warning {{ color: #f57c00; }}
.note {{ color: #1976d2; }}
.info {{ color: #388e3c; }}
</style>
</head>
<body>
<div class="header">
<h1>Security Findings Report</h1>
<p><strong>Run ID:</strong> {run_id}</p>
<p><strong>Tool:</strong> {tool.get('name', 'Unknown')} v{tool.get('version', 'Unknown')}</p>
<p><strong>Generated:</strong> {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
</div>
<div class="summary">
<h2>Summary</h2>
<p><strong>Total Issues:</strong> {len(results)}</p>
</div>
<div class="findings">
<h2>Detailed Findings</h2>
<table>
<thead>
<tr>
<th>Rule ID</th>
<th>Severity</th>
<th>Message</th>
<th>Location</th>
</tr>
</thead>
<tbody>
"""
for result in results:
level = result.get("level", "note")
rule_id = result.get("ruleId", "unknown")
message = result.get("message", {}).get("text", "")
# Extract location
location_str = ""
locations = result.get("locations", [])
if locations:
physical_location = locations[0].get("physicalLocation", {})
artifact_location = physical_location.get("artifactLocation", {})
region = physical_location.get("region", {})
file_path = artifact_location.get("uri", "")
if file_path:
location_str = file_path
if region.get("startLine"):
location_str += f":{region['startLine']}"
html_content += f"""
<tr>
<td>{rule_id}</td>
<td class="{level}">{level}</td>
<td>{message}</td>
<td>{location_str}</td>
</tr>
"""
html_content += """
</tbody>
</table>
</div>
</body>
</html>
"""
with open(output_path, 'w', encoding='utf-8') as f:
f.write(html_content)
@app.command("all")
def all_findings(
workflow: Optional[str] = typer.Option(
None, "--workflow", "-w",
help="Filter by workflow name"
),
severity: Optional[str] = typer.Option(
None, "--severity", "-s",
help="Filter by severity levels (comma-separated: error,warning,note,info)"
),
since: Optional[str] = typer.Option(
None, "--since",
help="Show findings since date (YYYY-MM-DD)"
),
limit: Optional[int] = typer.Option(
None, "--limit", "-l",
help="Maximum number of findings to show"
),
export_format: Optional[str] = typer.Option(
None, "--export", "-e",
help="Export format: json, csv, html"
),
output: Optional[str] = typer.Option(
None, "--output", "-o",
help="Output file for export"
),
stats_only: bool = typer.Option(
False, "--stats",
help="Show statistics only"
),
show_findings: bool = typer.Option(
False, "--show-findings", "-f",
help="Show actual findings content, not just summary"
),
max_findings: int = typer.Option(
50, "--max-findings",
help="Maximum number of individual findings to display"
)
):
"""
📊 Show all findings for the entire project
"""
db = get_project_db()
if not db:
console.print("❌ No FuzzForge project found. Run 'ff init' first.", style="red")
raise typer.Exit(1)
try:
# Parse filters
severity_list = None
if severity:
severity_list = [s.strip().lower() for s in severity.split(",")]
since_date = None
if since:
try:
since_date = datetime.strptime(since, "%Y-%m-%d")
except ValueError:
console.print(f"❌ Invalid date format: {since}. Use YYYY-MM-DD", style="red")
raise typer.Exit(1)
# Get aggregated stats
stats = db.get_aggregated_stats()
# Show statistics
if stats_only or not export_format:
# Create summary panel
summary_text = f"""[bold]📊 Project Security Summary[/bold]
[cyan]Total Findings Records:[/cyan] {stats['total_findings_records']}
[cyan]Total Runs Analyzed:[/cyan] {stats['total_runs']}
[cyan]Total Security Issues:[/cyan] {stats['total_issues']}
[cyan]Recent Findings (7 days):[/cyan] {stats['recent_findings']}
[bold]Severity Distribution:[/bold]
🔴 Errors: {stats['severity_distribution'].get('error', 0)}
🟡 Warnings: {stats['severity_distribution'].get('warning', 0)}
🔵 Notes: {stats['severity_distribution'].get('note', 0)}
️ Info: {stats['severity_distribution'].get('info', 0)}
[bold]By Workflow:[/bold]"""
for wf_name, count in stats['workflows'].items():
summary_text += f"\n{wf_name}: {count} findings"
console.print(Panel(summary_text, box=box.ROUNDED, title="FuzzForge Project Analysis", border_style="cyan"))
if stats_only:
return
# Get all findings with filters
findings = db.get_all_findings(
workflow=workflow,
severity=severity_list,
since_date=since_date,
limit=limit
)
if not findings:
console.print("️ No findings match the specified filters", style="dim")
return
# Export if requested
if export_format:
if not output:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output = f"all_findings_{timestamp}.{export_format}"
export_all_findings(findings, export_format, output)
console.print(f"✅ Exported {len(findings)} findings to: {output}", style="green")
return
# Display findings table
table = Table(box=box.ROUNDED, title=f"All Project Findings ({len(findings)} records)")
table.add_column("Run ID", style="bold cyan", width=36) # Full UUID width
table.add_column("Workflow", style="dim", width=20)
table.add_column("Date", justify="center")
table.add_column("Issues", justify="center", style="bold")
table.add_column("Errors", justify="center", style="red")
table.add_column("Warnings", justify="center", style="yellow")
table.add_column("Notes", justify="center", style="blue")
# Get run info for each finding
runs_info = {}
for finding in findings:
run_id = finding.run_id
if run_id not in runs_info:
run_info = db.get_run(run_id)
runs_info[run_id] = run_info
for finding in findings:
run_id = finding.run_id
run_info = runs_info.get(run_id)
workflow_name = run_info.workflow if run_info else "unknown"
summary = finding.summary
total_issues = summary.get("total_issues", 0)
by_severity = summary.get("by_severity", {})
# Count issues from SARIF data if summary is incomplete
if total_issues == 0 and "runs" in finding.sarif_data:
for run in finding.sarif_data["runs"]:
total_issues += len(run.get("results", []))
table.add_row(
run_id, # Show full Run ID
workflow_name[:17] + "..." if len(workflow_name) > 20 else workflow_name,
finding.created_at.strftime("%Y-%m-%d %H:%M"),
str(total_issues),
str(by_severity.get("error", 0)),
str(by_severity.get("warning", 0)),
str(by_severity.get("note", 0))
)
console.print(table)
# Show actual findings if requested
if show_findings:
display_detailed_findings(findings, max_findings)
console.print(f"\n💡 Use filters to refine results: --workflow, --severity, --since")
console.print(f"💡 Show findings content: --show-findings")
console.print(f"💡 Export findings: --export json --output report.json")
console.print(f"💡 View specific findings: [bold cyan]fuzzforge finding <run-id>[/bold cyan]")
except Exception as e:
console.print(f"❌ Failed to get all findings: {e}", style="red")
raise typer.Exit(1)
def display_detailed_findings(findings: List[FindingRecord], max_findings: int):
"""Display detailed findings content"""
console.print(f"\n📋 [bold]Detailed Findings Content[/bold] (showing up to {max_findings} findings)\n")
findings_count = 0
for finding_record in findings:
if findings_count >= max_findings:
remaining = sum(len(run.get("results", []))
for f in findings[findings.index(finding_record):]
for run in f.sarif_data.get("runs", []))
if remaining > 0:
console.print(f"\n... and {remaining} more findings (use --max-findings to show more)")
break
# Get run info for this finding
sarif_data = finding_record.sarif_data
if not sarif_data or "runs" not in sarif_data:
continue
for run in sarif_data["runs"]:
tool = run.get("tool", {})
driver = tool.get("driver", {})
tool_name = driver.get("name", "Unknown Tool")
results = run.get("results", [])
if not results:
continue
# Group results by severity
for result in results:
if findings_count >= max_findings:
break
findings_count += 1
# Extract key information
rule_id = result.get("ruleId", "unknown")
level = result.get("level", "note").upper()
message_text = result.get("message", {}).get("text", "No description")
# Get location information
locations = result.get("locations", [])
location_str = "Unknown location"
if locations:
physical = locations[0].get("physicalLocation", {})
artifact = physical.get("artifactLocation", {})
region = physical.get("region", {})
file_path = artifact.get("uri", "")
line_number = region.get("startLine", "")
if file_path:
location_str = f"{file_path}"
if line_number:
location_str += f":{line_number}"
# Get severity style
severity_style = {
"ERROR": "bold red",
"WARNING": "bold yellow",
"NOTE": "bold blue",
"INFO": "bold cyan"
}.get(level, "white")
# Create finding panel
finding_content = f"""[bold]Rule:[/bold] {rule_id}
[bold]Location:[/bold] {location_str}
[bold]Tool:[/bold] {tool_name}
[bold]Run:[/bold] {finding_record.run_id[:12]}...
[bold]Description:[/bold]
{message_text}"""
# Add code context if available
region = locations[0].get("physicalLocation", {}).get("region", {}) if locations else {}
if region.get("snippet", {}).get("text"):
code_snippet = region["snippet"]["text"].strip()
finding_content += f"\n\n[bold]Code:[/bold]\n[dim]{code_snippet}[/dim]"
console.print(Panel(
finding_content,
title=f"[{severity_style}]{level}[/{severity_style}] Finding #{findings_count}",
border_style=severity_style.split()[-1] if " " in severity_style else severity_style,
box=box.ROUNDED
))
console.print() # Add spacing between findings
def export_all_findings(findings: List[FindingRecord], format: str, output_path: str):
"""Export all findings to specified format"""
output_file = Path(output_path)
if format == "json":
# Combine all SARIF data
all_results = []
for finding in findings:
if "runs" in finding.sarif_data:
for run in finding.sarif_data["runs"]:
for result in run.get("results", []):
result_entry = {
"run_id": finding.run_id,
"created_at": finding.created_at.isoformat(),
**result
}
all_results.append(result_entry)
with open(output_file, 'w') as f:
json.dump({
"total_findings": len(findings),
"export_date": datetime.now().isoformat(),
"results": all_results
}, f, indent=2)
elif format == "csv":
# Export to CSV
with open(output_file, 'w', newline='') as f:
writer = csv.writer(f)
writer.writerow(["Run ID", "Date", "Severity", "Rule ID", "Message", "File", "Line"])
for finding in findings:
if "runs" in finding.sarif_data:
for run in finding.sarif_data["runs"]:
for result in run.get("results", []):
locations = result.get("locations", [])
location_info = locations[0] if locations else {}
physical = location_info.get("physicalLocation", {})
artifact = physical.get("artifactLocation", {})
region = physical.get("region", {})
writer.writerow([
finding.run_id[:12],
finding.created_at.strftime("%Y-%m-%d %H:%M"),
result.get("level", "note"),
result.get("ruleId", ""),
result.get("message", {}).get("text", ""),
artifact.get("uri", ""),
region.get("startLine", "")
])
elif format == "html":
# Generate HTML report
html_content = f"""<!DOCTYPE html>
<html>
<head>
<title>FuzzForge Security Findings Report</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 20px; }}
h1 {{ color: #333; }}
.stats {{ background: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0; }}
table {{ width: 100%; border-collapse: collapse; }}
th, td {{ padding: 10px; text-align: left; border-bottom: 1px solid #ddd; }}
th {{ background: #4CAF50; color: white; }}
.error {{ color: red; font-weight: bold; }}
.warning {{ color: orange; font-weight: bold; }}
.note {{ color: blue; }}
.info {{ color: gray; }}
</style>
</head>
<body>
<h1>FuzzForge Security Findings Report</h1>
<div class="stats">
<p><strong>Generated:</strong> {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</p>
<p><strong>Total Findings:</strong> {len(findings)}</p>
</div>
<table>
<tr>
<th>Run ID</th>
<th>Date</th>
<th>Severity</th>
<th>Rule</th>
<th>Message</th>
<th>Location</th>
</tr>"""
for finding in findings:
if "runs" in finding.sarif_data:
for run in finding.sarif_data["runs"]:
for result in run.get("results", []):
level = result.get("level", "note")
locations = result.get("locations", [])
location_info = locations[0] if locations else {}
physical = location_info.get("physicalLocation", {})
artifact = physical.get("artifactLocation", {})
region = physical.get("region", {})
html_content += f"""
<tr>
<td>{finding.run_id[:12]}</td>
<td>{finding.created_at.strftime("%Y-%m-%d %H:%M")}</td>
<td class="{level}">{level.upper()}</td>
<td>{result.get("ruleId", "")}</td>
<td>{result.get("message", {}).get("text", "")}</td>
<td>{artifact.get("uri", "")} : {region.get("startLine", "")}</td>
</tr>"""
html_content += """
</table>
</body>
</html>"""
with open(output_file, 'w') as f:
f.write(html_content)
@app.callback(invoke_without_command=True)
def findings_callback(ctx: typer.Context):
"""
🔍 View and export security findings
"""
# Check if a subcommand is being invoked
if ctx.invoked_subcommand is not None:
# Let the subcommand handle it
return
# Default to history when no subcommand provided
findings_history(limit=20)
+251
View File
@@ -0,0 +1,251 @@
"""Cognee ingestion commands for FuzzForge CLI."""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
from __future__ import annotations
import asyncio
import os
from pathlib import Path
from typing import List, Optional
import typer
from rich.console import Console
from rich.prompt import Confirm
from ..config import ProjectConfigManager
from ..ingest_utils import collect_ingest_files
console = Console()
app = typer.Typer(
name="ingest",
help="Ingest files or directories into the Cognee knowledge graph for the current project",
invoke_without_command=True,
)
@app.callback()
def ingest_callback(
ctx: typer.Context,
path: Optional[Path] = typer.Argument(
None,
exists=True,
file_okay=True,
dir_okay=True,
readable=True,
resolve_path=True,
help="File or directory to ingest (defaults to current directory)",
),
recursive: bool = typer.Option(
False,
"--recursive",
"-r",
help="Recursively ingest directories",
),
file_types: Optional[List[str]] = typer.Option(
None,
"--file-types",
"-t",
help="File extensions to include (e.g. --file-types .py --file-types .js)",
),
exclude: Optional[List[str]] = typer.Option(
None,
"--exclude",
"-e",
help="Glob patterns to exclude",
),
dataset: Optional[str] = typer.Option(
None,
"--dataset",
"-d",
help="Dataset name to ingest into",
),
force: bool = typer.Option(
False,
"--force",
"-f",
help="Force re-ingestion and skip confirmation",
),
):
"""Entry point for `fuzzforge ingest` when no subcommand is provided."""
if ctx.invoked_subcommand:
return
try:
config = ProjectConfigManager()
except FileNotFoundError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1) from exc
if not config.is_initialized():
console.print("[red]Error: FuzzForge project not initialized. Run 'ff init' first.[/red]")
raise typer.Exit(1)
config.setup_cognee_environment()
if os.getenv("FUZZFORGE_DEBUG", "0") == "1":
console.print(
"[dim]Cognee directories:\n"
f" DATA: {os.getenv('COGNEE_DATA_ROOT', 'unset')}\n"
f" SYSTEM: {os.getenv('COGNEE_SYSTEM_ROOT', 'unset')}\n"
f" USER: {os.getenv('COGNEE_USER_ID', 'unset')}\n",
)
project_context = config.get_project_context()
target_path = path or Path.cwd()
dataset_name = dataset or f"{project_context['project_name']}_codebase"
try:
import cognee # noqa: F401 # Just to validate installation
except ImportError as exc:
console.print("[red]Cognee is not installed.[/red]")
console.print("Install with: pip install 'cognee[all]' litellm")
raise typer.Exit(1) from exc
console.print(f"[bold]🔍 Ingesting {target_path} into Cognee knowledge graph[/bold]")
console.print(
f"Project: [cyan]{project_context['project_name']}[/cyan] "
f"(ID: [dim]{project_context['project_id']}[/dim])"
)
console.print(f"Dataset: [cyan]{dataset_name}[/cyan]")
console.print(f"Tenant: [dim]{project_context['tenant_id']}[/dim]")
if not force:
confirm_message = f"Ingest {target_path} into knowledge graph for this project?"
if not Confirm.ask(confirm_message, console=console):
console.print("[yellow]Ingestion cancelled[/yellow]")
raise typer.Exit(0)
try:
asyncio.run(
_run_ingestion(
config=config,
path=target_path.resolve(),
recursive=recursive,
file_types=file_types,
exclude=exclude,
dataset=dataset_name,
force=force,
)
)
except KeyboardInterrupt:
console.print("\n[yellow]Ingestion cancelled by user[/yellow]")
raise typer.Exit(1)
except Exception as exc: # pragma: no cover - rich reporting
console.print(f"[red]Failed to ingest:[/red] {exc}")
raise typer.Exit(1) from exc
async def _run_ingestion(
*,
config: ProjectConfigManager,
path: Path,
recursive: bool,
file_types: Optional[List[str]],
exclude: Optional[List[str]],
dataset: str,
force: bool,
) -> None:
"""Perform the actual ingestion work."""
from fuzzforge_ai.cognee_service import CogneeService
cognee_service = CogneeService(config)
await cognee_service.initialize()
# Always skip internal bookkeeping directories
exclude_patterns = list(exclude or [])
default_excludes = {
".fuzzforge/**",
".git/**",
}
added_defaults = []
for pattern in default_excludes:
if pattern not in exclude_patterns:
exclude_patterns.append(pattern)
added_defaults.append(pattern)
if added_defaults and os.getenv("FUZZFORGE_DEBUG", "0") == "1":
console.print(
"[dim]Auto-excluding paths: {patterns}[/dim]".format(
patterns=", ".join(added_defaults)
)
)
try:
files_to_ingest = collect_ingest_files(path, recursive, file_types, exclude_patterns)
except Exception as exc:
console.print(f"[red]Failed to collect files:[/red] {exc}")
return
if not files_to_ingest:
console.print("[yellow]No files found to ingest[/yellow]")
return
console.print(f"Found [green]{len(files_to_ingest)}[/green] files to ingest")
if force:
console.print("Cleaning existing data for this project...")
try:
await cognee_service.clear_data(confirm=True)
except Exception as exc:
console.print(f"[yellow]Warning:[/yellow] Could not clean existing data: {exc}")
console.print("Adding files to Cognee...")
valid_file_paths = []
for file_path in files_to_ingest:
try:
with open(file_path, "r", encoding="utf-8") as fh:
fh.read(1)
valid_file_paths.append(file_path)
console.print(f"{file_path}")
except (UnicodeDecodeError, PermissionError) as exc:
console.print(f"[yellow]Skipping {file_path}: {exc}[/yellow]")
if not valid_file_paths:
console.print("[yellow]No readable files found to ingest[/yellow]")
return
results = await cognee_service.ingest_files(valid_file_paths, dataset)
console.print(
f"[green]✅ Successfully ingested {results['success']} files into knowledge graph[/green]"
)
if results["failed"]:
console.print(
f"[yellow]⚠️ Skipped {results['failed']} files due to errors[/yellow]"
)
try:
insights = await cognee_service.search_insights(
query=f"What insights can you provide about the {dataset} dataset?",
dataset=dataset,
)
if insights:
console.print(f"\n[bold]📊 Generated {len(insights)} insights:[/bold]")
for index, insight in enumerate(insights[:3], 1):
console.print(f" {index}. {insight}")
if len(insights) > 3:
console.print(f" ... and {len(insights) - 3} more")
chunks = await cognee_service.search_chunks(
query=f"functions classes methods in {dataset}",
dataset=dataset,
)
if chunks:
console.print(
f"\n[bold]🔍 Sample searchable content ({len(chunks)} chunks found):[/bold]"
)
for index, chunk in enumerate(chunks[:2], 1):
preview = chunk[:100] + "..." if len(chunk) > 100 else chunk
console.print(f" {index}. {preview}")
except Exception:
# Best-effort stats — ignore failures here
pass
+282
View File
@@ -0,0 +1,282 @@
"""Project initialization commands."""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
from __future__ import annotations
from pathlib import Path
import os
from textwrap import dedent
from typing import Optional
import typer
from rich.console import Console
from rich.prompt import Confirm, Prompt
from ..config import ensure_project_config
from ..database import ensure_project_db
console = Console()
app = typer.Typer()
@app.command()
def project(
name: Optional[str] = typer.Option(
None, "--name", "-n",
help="Project name (defaults to current directory name)"
),
api_url: Optional[str] = typer.Option(
None, "--api-url", "-u",
help="FuzzForge API URL (defaults to http://localhost:8000)"
),
force: bool = typer.Option(
False, "--force", "-f",
help="Force initialization even if project already exists"
)
):
"""
📁 Initialize a new FuzzForge project in the current directory.
This creates a .fuzzforge directory with:
• SQLite database for storing runs, findings, and crashes
• Configuration file with project settings
• Default ignore patterns and preferences
"""
current_dir = Path.cwd()
fuzzforge_dir = current_dir / ".fuzzforge"
# Check if project already exists
if fuzzforge_dir.exists() and not force:
if fuzzforge_dir.is_dir() and any(fuzzforge_dir.iterdir()):
console.print("❌ FuzzForge project already exists in this directory", style="red")
console.print("Use --force to reinitialize", style="dim")
raise typer.Exit(1)
# Get project name
if not name:
name = Prompt.ask(
"Project name",
default=current_dir.name,
console=console
)
# Get API URL
if not api_url:
api_url = Prompt.ask(
"FuzzForge API URL",
default="http://localhost:8000",
console=console
)
# Confirm initialization
console.print(f"\n📁 Initializing FuzzForge project: [bold cyan]{name}[/bold cyan]")
console.print(f"📍 Location: [dim]{current_dir}[/dim]")
console.print(f"🔗 API URL: [dim]{api_url}[/dim]")
if not Confirm.ask("\nProceed with initialization?", default=True, console=console):
console.print("❌ Initialization cancelled", style="yellow")
raise typer.Exit(0)
try:
# Create .fuzzforge directory
console.print("\n🔨 Creating project structure...")
fuzzforge_dir.mkdir(exist_ok=True)
# Initialize configuration
console.print("⚙️ Setting up configuration...")
ensure_project_config(
project_dir=current_dir,
project_name=name,
api_url=api_url,
)
# Initialize database
console.print("🗄️ Initializing database...")
ensure_project_db(current_dir)
_ensure_env_file(fuzzforge_dir, force)
_ensure_agents_registry(fuzzforge_dir, force)
# Create .gitignore if needed
gitignore_path = current_dir / ".gitignore"
gitignore_entries = [
"# FuzzForge CLI",
".fuzzforge/findings.db-*", # SQLite temp files
".fuzzforge/cache/",
".fuzzforge/temp/",
]
if gitignore_path.exists():
with open(gitignore_path, 'r') as f:
existing_content = f.read()
if "# FuzzForge CLI" not in existing_content:
with open(gitignore_path, 'a') as f:
f.write(f"\n{chr(10).join(gitignore_entries)}\n")
console.print("📝 Updated .gitignore with FuzzForge entries")
else:
with open(gitignore_path, 'w') as f:
f.write(f"{chr(10).join(gitignore_entries)}\n")
console.print("📝 Created .gitignore")
# Create README if it doesn't exist
readme_path = current_dir / "README.md"
if not readme_path.exists():
readme_content = f"""# {name}
FuzzForge security testing project.
## Quick Start
```bash
# List available workflows
fuzzforge workflows
# Submit a workflow for analysis
fuzzforge workflow <workflow-name> /path/to/target
# Monitor run progress
fuzzforge monitor live <run-id>
# View findings
fuzzforge finding <run-id>
```
## Project Structure
- `.fuzzforge/` - Project data and configuration
- `.fuzzforge/config.yaml` - Project configuration
- `.fuzzforge/findings.db` - Local database for runs and findings
"""
with open(readme_path, 'w') as f:
f.write(readme_content)
console.print("📚 Created README.md")
console.print("\n✅ FuzzForge project initialized successfully!", style="green")
console.print(f"\n🎯 Next steps:")
console.print(" • ff workflows - See available workflows")
console.print(" • ff status - Check API connectivity")
console.print(" • ff workflow <workflow> <path> - Start your first analysis")
console.print(" • edit .fuzzforge/.env with API keys & provider settings")
except Exception as e:
console.print(f"\n❌ Initialization failed: {e}", style="red")
raise typer.Exit(1)
@app.callback()
def init_callback():
"""
📁 Initialize FuzzForge projects and components
"""
def _ensure_env_file(fuzzforge_dir: Path, force: bool) -> None:
"""Create or update the .fuzzforge/.env file with AI defaults."""
env_path = fuzzforge_dir / ".env"
if env_path.exists() and not force:
console.print("🧪 Using existing .fuzzforge/.env (use --force to regenerate)")
return
console.print("🧠 Configuring AI environment...")
console.print(" • Default LLM provider: openai")
console.print(" • Default LLM model: gpt-5-mini")
console.print(" • To customise provider/model later, edit .fuzzforge/.env")
llm_provider = "openai"
llm_model = "gpt-5-mini"
api_key = Prompt.ask(
"OpenAI API key (leave blank to fill manually)",
default="",
show_default=False,
console=console,
)
enable_cognee = False
cognee_url = ""
session_db_path = fuzzforge_dir / "fuzzforge_sessions.db"
session_db_rel = session_db_path.relative_to(fuzzforge_dir.parent)
env_lines = [
"# FuzzForge AI configuration",
"# Populate the API key(s) that match your LLM provider",
"",
f"LLM_PROVIDER={llm_provider}",
f"LLM_MODEL={llm_model}",
f"LITELLM_MODEL={llm_model}",
f"OPENAI_API_KEY={api_key}",
f"FUZZFORGE_MCP_URL={os.getenv('FUZZFORGE_MCP_URL', 'http://localhost:8010/mcp')}",
"",
"# Cognee configuration mirrors the primary LLM by default",
f"LLM_COGNEE_PROVIDER={llm_provider}",
f"LLM_COGNEE_MODEL={llm_model}",
f"LLM_COGNEE_API_KEY={api_key}",
"LLM_COGNEE_ENDPOINT=",
"COGNEE_MCP_URL=",
"",
"# Session persistence options: inmemory | sqlite",
"SESSION_PERSISTENCE=sqlite",
f"SESSION_DB_PATH={session_db_rel}",
"",
"# Optional integrations",
"AGENTOPS_API_KEY=",
"FUZZFORGE_DEBUG=0",
"",
]
env_path.write_text("\n".join(env_lines), encoding="utf-8")
console.print(f"📝 Created {env_path.relative_to(fuzzforge_dir.parent)}")
template_path = fuzzforge_dir / ".env.template"
if not template_path.exists() or force:
template_lines = []
for line in env_lines:
if line.startswith("OPENAI_API_KEY="):
template_lines.append("OPENAI_API_KEY=")
elif line.startswith("LLM_COGNEE_API_KEY="):
template_lines.append("LLM_COGNEE_API_KEY=")
else:
template_lines.append(line)
template_path.write_text("\n".join(template_lines), encoding="utf-8")
console.print(f"📝 Created {template_path.relative_to(fuzzforge_dir.parent)}")
# SQLite session DB will be created automatically when first used by the AI agent
def _ensure_agents_registry(fuzzforge_dir: Path, force: bool) -> None:
"""Create a starter agents.yaml registry if needed."""
agents_path = fuzzforge_dir / "agents.yaml"
if agents_path.exists() and not force:
return
template = dedent(
"""\
# FuzzForge Registered Agents
# Populate this list to auto-register remote agents when the AI CLI starts
registered_agents: []
# Example:
# registered_agents:
# - name: Calculator
# url: http://localhost:10201
# description: Sample math agent
""".strip()
)
agents_path.write_text(template + "\n", encoding="utf-8")
console.print(f"📝 Created {agents_path.relative_to(fuzzforge_dir.parent)}")
+436
View File
@@ -0,0 +1,436 @@
"""
Real-time monitoring and statistics commands.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import asyncio
import time
from datetime import datetime, timedelta
from typing import Optional
import typer
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.live import Live
from rich.layout import Layout
from rich.progress import Progress, BarColumn, TextColumn, SpinnerColumn
from rich.align import Align
from rich import box
from ..config import get_project_config, FuzzForgeConfig
from ..database import get_project_db, ensure_project_db, CrashRecord
from fuzzforge_sdk import FuzzForgeClient
console = Console()
app = typer.Typer()
def get_client() -> FuzzForgeClient:
"""Get configured FuzzForge client"""
config = get_project_config() or FuzzForgeConfig()
return FuzzForgeClient(base_url=config.get_api_url(), timeout=config.get_timeout())
def format_duration(seconds: int) -> str:
"""Format duration in human readable format"""
if seconds < 60:
return f"{seconds}s"
elif seconds < 3600:
return f"{seconds // 60}m {seconds % 60}s"
else:
hours = seconds // 3600
minutes = (seconds % 3600) // 60
return f"{hours}h {minutes}m"
def format_number(num: int) -> str:
"""Format large numbers with K, M suffixes"""
if num >= 1000000:
return f"{num / 1000000:.1f}M"
elif num >= 1000:
return f"{num / 1000:.1f}K"
else:
return str(num)
@app.command("stats")
def fuzzing_stats(
run_id: str = typer.Argument(..., help="Run ID to get statistics for"),
refresh: int = typer.Option(
5, "--refresh", "-r",
help="Refresh interval in seconds"
),
once: bool = typer.Option(
False, "--once",
help="Show stats once and exit"
)
):
"""
📊 Show current fuzzing statistics for a run
"""
try:
with get_client() as client:
if once:
# Show stats once
stats = client.get_fuzzing_stats(run_id)
display_stats_table(stats)
else:
# Live updating stats
console.print(f"📊 [bold]Live Fuzzing Statistics[/bold] (Run: {run_id[:12]}...)")
console.print(f"Refreshing every {refresh}s. Press Ctrl+C to stop.\n")
with Live(auto_refresh=False, console=console) as live:
while True:
try:
stats = client.get_fuzzing_stats(run_id)
table = create_stats_table(stats)
live.update(table, refresh=True)
time.sleep(refresh)
except KeyboardInterrupt:
console.print("\n📊 Monitoring stopped", style="yellow")
break
except Exception as e:
console.print(f"❌ Failed to get fuzzing stats: {e}", style="red")
raise typer.Exit(1)
def display_stats_table(stats):
"""Display stats in a simple table"""
table = create_stats_table(stats)
console.print(table)
def create_stats_table(stats) -> Panel:
"""Create a rich table for fuzzing statistics"""
# Create main stats table
stats_table = Table(show_header=False, box=box.SIMPLE)
stats_table.add_column("Metric", style="bold cyan")
stats_table.add_column("Value", justify="right", style="bold white")
stats_table.add_row("Total Executions", format_number(stats.executions))
stats_table.add_row("Executions/sec", f"{stats.executions_per_sec:.1f}")
stats_table.add_row("Total Crashes", format_number(stats.crashes))
stats_table.add_row("Unique Crashes", format_number(stats.unique_crashes))
if stats.coverage is not None:
stats_table.add_row("Code Coverage", f"{stats.coverage:.1f}%")
stats_table.add_row("Corpus Size", format_number(stats.corpus_size))
stats_table.add_row("Elapsed Time", format_duration(stats.elapsed_time))
if stats.last_crash_time:
time_since_crash = datetime.now() - stats.last_crash_time
stats_table.add_row("Last Crash", f"{format_duration(int(time_since_crash.total_seconds()))} ago")
return Panel.fit(
stats_table,
title=f"📊 Fuzzing Statistics - {stats.workflow}",
subtitle=f"Run: {stats.run_id[:12]}...",
box=box.ROUNDED
)
@app.command("crashes")
def crash_reports(
run_id: str = typer.Argument(..., help="Run ID to get crash reports for"),
save: bool = typer.Option(
True, "--save/--no-save",
help="Save crashes to local database"
),
limit: int = typer.Option(
50, "--limit", "-l",
help="Maximum number of crashes to show"
)
):
"""
🐛 Display crash reports for a fuzzing run
"""
try:
with get_client() as client:
console.print(f"🐛 Fetching crash reports for run: {run_id}")
crashes = client.get_crash_reports(run_id)
if not crashes:
console.print("✅ No crashes found!", style="green")
return
# Save to database if requested
if save:
db = ensure_project_db()
for crash in crashes:
crash_record = CrashRecord(
run_id=run_id,
crash_id=crash.crash_id,
signal=crash.signal,
stack_trace=crash.stack_trace,
input_file=crash.input_file,
severity=crash.severity,
timestamp=crash.timestamp
)
db.save_crash(crash_record)
console.print("✅ Crashes saved to local database")
# Display crashes
crashes_to_show = crashes[:limit]
# Summary
severity_counts = {}
signal_counts = {}
for crash in crashes:
severity_counts[crash.severity] = severity_counts.get(crash.severity, 0) + 1
if crash.signal:
signal_counts[crash.signal] = signal_counts.get(crash.signal, 0) + 1
summary_table = Table(show_header=False, box=box.SIMPLE)
summary_table.add_column("Metric", style="bold cyan")
summary_table.add_column("Value", justify="right")
summary_table.add_row("Total Crashes", str(len(crashes)))
summary_table.add_row("Unique Signals", str(len(signal_counts)))
for severity, count in sorted(severity_counts.items()):
summary_table.add_row(f"{severity.title()} Severity", str(count))
console.print(
Panel.fit(
summary_table,
title=f"🐛 Crash Summary",
box=box.ROUNDED
)
)
# Detailed crash table
if crashes_to_show:
crashes_table = Table(box=box.ROUNDED)
crashes_table.add_column("Crash ID", style="bold cyan")
crashes_table.add_column("Signal", justify="center")
crashes_table.add_column("Severity", justify="center")
crashes_table.add_column("Timestamp", justify="center")
crashes_table.add_column("Input File", style="dim")
for crash in crashes_to_show:
signal_emoji = {
"SIGSEGV": "💥",
"SIGABRT": "🛑",
"SIGFPE": "🧮",
"SIGILL": "⚠️"
}.get(crash.signal or "", "🐛")
severity_style = {
"high": "red",
"medium": "yellow",
"low": "green"
}.get(crash.severity.lower(), "white")
input_display = ""
if crash.input_file:
input_display = crash.input_file.split("/")[-1] # Show just filename
crashes_table.add_row(
crash.crash_id[:12] + "..." if len(crash.crash_id) > 15 else crash.crash_id,
f"{signal_emoji} {crash.signal or 'Unknown'}",
f"[{severity_style}]{crash.severity}[/{severity_style}]",
crash.timestamp.strftime("%H:%M:%S"),
input_display
)
console.print(f"\n🐛 [bold]Crash Details[/bold]")
if len(crashes) > limit:
console.print(f"Showing first {limit} of {len(crashes)} crashes")
console.print()
console.print(crashes_table)
console.print(f"\n💡 Use [bold cyan]fuzzforge finding {run_id}[/bold cyan] for detailed analysis")
except Exception as e:
console.print(f"❌ Failed to get crash reports: {e}", style="red")
raise typer.Exit(1)
def _live_monitor(run_id: str, refresh: int):
"""Helper for live monitoring to allow for cleaner exit handling"""
with get_client() as client:
start_time = time.time()
def render_layout(run_status, stats):
layout = Layout()
layout.split_column(
Layout(name="header", size=3),
Layout(name="main", ratio=1),
Layout(name="footer", size=3)
)
layout["main"].split_row(
Layout(name="stats", ratio=1),
Layout(name="progress", ratio=1)
)
header = Panel(
f"[bold]FuzzForge Live Monitor[/bold]\n"
f"Run: {run_id[:12]}... | Status: {run_status.status} | "
f"Uptime: {format_duration(int(time.time() - start_time))}",
box=box.ROUNDED,
style="cyan"
)
layout["header"].update(header)
layout["stats"].update(create_stats_table(stats))
progress_table = Table(show_header=False, box=box.SIMPLE)
progress_table.add_column("Metric", style="bold")
progress_table.add_column("Progress")
if stats.executions > 0:
exec_rate_percent = min(100, (stats.executions_per_sec / 1000) * 100)
progress_table.add_row("Exec Rate", create_progress_bar(exec_rate_percent, "green"))
crash_rate = (stats.crashes / stats.executions) * 100000
crash_rate_percent = min(100, crash_rate * 10)
progress_table.add_row("Crash Rate", create_progress_bar(crash_rate_percent, "red"))
if stats.coverage is not None:
progress_table.add_row("Coverage", create_progress_bar(stats.coverage, "blue"))
layout["progress"].update(Panel.fit(progress_table, title="📊 Progress Indicators", box=box.ROUNDED))
footer = Panel(
f"Last updated: {datetime.now().strftime('%H:%M:%S')} | "
f"Refresh interval: {refresh}s | Press Ctrl+C to exit",
box=box.ROUNDED,
style="dim"
)
layout["footer"].update(footer)
return layout
with Live(auto_refresh=False, console=console, screen=True) as live:
# Initial fetch
try:
run_status = client.get_run_status(run_id)
stats = client.get_fuzzing_stats(run_id)
except Exception:
# Minimal fallback stats
class FallbackStats:
def __init__(self, run_id):
self.run_id = run_id
self.workflow = "unknown"
self.executions = 0
self.executions_per_sec = 0.0
self.crashes = 0
self.unique_crashes = 0
self.coverage = None
self.corpus_size = 0
self.elapsed_time = 0
self.last_crash_time = None
stats = FallbackStats(run_id)
run_status = type("RS", (), {"status":"Unknown","is_completed":False,"is_failed":False})()
live.update(render_layout(run_status, stats), refresh=True)
# Simple polling approach that actually works
consecutive_errors = 0
max_errors = 5
while True:
try:
# Poll for updates
try:
run_status = client.get_run_status(run_id)
consecutive_errors = 0
except Exception as e:
consecutive_errors += 1
if consecutive_errors >= max_errors:
console.print(f"❌ Too many errors getting run status: {e}", style="red")
break
time.sleep(refresh)
continue
# Try to get fuzzing stats
try:
stats = client.get_fuzzing_stats(run_id)
except Exception as e:
# Create fallback stats if not available
stats = FallbackStats(run_id)
# Update display
live.update(render_layout(run_status, stats), refresh=True)
# Check if completed
if getattr(run_status, 'is_completed', False) or getattr(run_status, 'is_failed', False):
# Show final state for a few seconds
console.print("\n🏁 Run completed. Showing final state for 10 seconds...")
time.sleep(10)
break
# Wait before next poll
time.sleep(refresh)
except KeyboardInterrupt:
raise
except Exception as e:
console.print(f"⚠️ Monitoring error: {e}", style="yellow")
time.sleep(refresh)
# Completed status update
final_message = (
f"[bold]FuzzForge Live Monitor - COMPLETED[/bold]\n"
f"Run: {run_id[:12]}... | Status: {run_status.status} | "
f"Total runtime: {format_duration(int(time.time() - start_time))}"
)
style = "green" if getattr(run_status, 'is_completed', False) else "red"
live.update(Panel(final_message, box=box.ROUNDED, style=style), refresh=True)
@app.command("live")
def live_monitor(
run_id: str = typer.Argument(..., help="Run ID to monitor live"),
refresh: int = typer.Option(
2, "--refresh", "-r",
help="Refresh interval in seconds (fallback when streaming unavailable)"
)
):
"""
📺 Real-time monitoring dashboard with live updates (WebSocket/SSE with REST fallback)
"""
console.print(f"📺 [bold]Live Monitoring Dashboard[/bold]")
console.print(f"Run: {run_id}")
console.print(f"Press Ctrl+C to stop monitoring\n")
try:
_live_monitor(run_id, refresh)
except KeyboardInterrupt:
console.print("\n📊 Monitoring stopped by user.", style="yellow")
except Exception as e:
console.print(f"❌ Failed to start live monitoring: {e}", style="red")
raise typer.Exit(1)
def create_progress_bar(percentage: float, color: str = "green") -> str:
"""Create a simple text progress bar"""
width = 20
filled = int((percentage / 100) * width)
bar = "" * filled + "" * (width - filled)
return f"[{color}]{bar}[/{color}] {percentage:.1f}%"
@app.callback(invoke_without_command=True)
def monitor_callback(ctx: typer.Context):
"""
📊 Real-time monitoring and statistics
"""
# Check if a subcommand is being invoked
if ctx.invoked_subcommand is not None:
# Let the subcommand handle it
return
# Show not implemented message for default command
from rich.console import Console
console = Console()
console.print("🚧 [yellow]Monitor command is not fully implemented yet.[/yellow]")
console.print("Please use specific subcommands:")
console.print(" • [cyan]ff monitor stats <run-id>[/cyan] - Show execution statistics")
console.print(" • [cyan]ff monitor crashes <run-id>[/cyan] - Show crash reports")
console.print(" • [cyan]ff monitor live <run-id>[/cyan] - Live monitoring dashboard")
+165
View File
@@ -0,0 +1,165 @@
"""
Status command for showing project and API information.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
from pathlib import Path
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich import box
from ..config import get_project_config, FuzzForgeConfig
from ..database import get_project_db
from fuzzforge_sdk import FuzzForgeClient
console = Console()
def show_status():
"""Show comprehensive project and API status"""
current_dir = Path.cwd()
fuzzforge_dir = current_dir / ".fuzzforge"
# Project status
console.print("\n📊 [bold]FuzzForge Project Status[/bold]\n")
if not fuzzforge_dir.exists():
console.print(
Panel.fit(
"❌ No FuzzForge project found in current directory\n\n"
"Run [bold cyan]ff init[/bold cyan] to initialize a project",
title="Project Status",
box=box.ROUNDED
)
)
return
# Load project configuration
config = get_project_config()
if not config:
config = FuzzForgeConfig()
# Project info table
project_table = Table(show_header=False, box=box.SIMPLE)
project_table.add_column("Property", style="bold cyan")
project_table.add_column("Value")
project_table.add_row("Project Name", config.project.name)
project_table.add_row("Location", str(current_dir))
project_table.add_row("API URL", config.project.api_url)
project_table.add_row("Default Timeout", f"{config.project.default_timeout}s")
console.print(
Panel.fit(
project_table,
title="✅ Project Information",
box=box.ROUNDED
)
)
# Database status
db = get_project_db()
if db:
try:
stats = db.get_stats()
db_table = Table(show_header=False, box=box.SIMPLE)
db_table.add_column("Metric", style="bold cyan")
db_table.add_column("Count", justify="right")
db_table.add_row("Total Runs", str(stats["total_runs"]))
db_table.add_row("Total Findings", str(stats["total_findings"]))
db_table.add_row("Total Crashes", str(stats["total_crashes"]))
db_table.add_row("Runs (Last 7 days)", str(stats["runs_last_7_days"]))
if stats["runs_by_status"]:
db_table.add_row("", "") # Spacer
for status, count in stats["runs_by_status"].items():
status_emoji = {
"completed": "",
"running": "🔄",
"failed": "",
"queued": "",
"cancelled": "⏹️"
}.get(status, "📋")
db_table.add_row(f"{status_emoji} {status.title()}", str(count))
console.print(
Panel.fit(
db_table,
title="🗄️ Database Statistics",
box=box.ROUNDED
)
)
except Exception as e:
console.print(f"⚠️ Database error: {e}", style="yellow")
# API status
console.print("\n🔗 [bold]API Connectivity[/bold]")
try:
with FuzzForgeClient(base_url=config.get_api_url(), timeout=10.0) as client:
api_status = client.get_api_status()
workflows = client.list_workflows()
api_table = Table(show_header=False, box=box.SIMPLE)
api_table.add_column("Property", style="bold cyan")
api_table.add_column("Value")
api_table.add_row("Status", f"✅ Connected")
api_table.add_row("Service", f"{api_status.name} v{api_status.version}")
api_table.add_row("Workflows", str(len(workflows)))
console.print(
Panel.fit(
api_table,
title="✅ API Status",
box=box.ROUNDED
)
)
# Show available workflows
if workflows:
workflow_table = Table(box=box.SIMPLE_HEAD)
workflow_table.add_column("Name", style="bold")
workflow_table.add_column("Version", justify="center")
workflow_table.add_column("Description")
for workflow in workflows[:10]: # Limit to first 10
workflow_table.add_row(
workflow.name,
workflow.version,
workflow.description[:60] + "..." if len(workflow.description) > 60 else workflow.description
)
if len(workflows) > 10:
workflow_table.add_row("...", "...", f"and {len(workflows) - 10} more workflows")
console.print(
Panel.fit(
workflow_table,
title=f"🔧 Available Workflows ({len(workflows)})",
box=box.ROUNDED
)
)
except Exception as e:
console.print(
Panel.fit(
f"❌ Failed to connect to API\n\n"
f"Error: {str(e)}\n\n"
f"API URL: {config.get_api_url()}\n\n"
"Check that the FuzzForge API is running and accessible.",
title="❌ API Connection Failed",
box=box.ROUNDED
)
)
@@ -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
"""
+305
View File
@@ -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()