mirror of
https://github.com/Rarebuffalo/securelens-backend.git
synced 2026-06-19 07:00:30 +00:00
repl scan feature
This commit is contained in:
@@ -5,35 +5,36 @@ Post-scan Q&A loop — the "Gemini CLI feel".
|
||||
|
||||
After a scan completes, the user drops into this loop where they can:
|
||||
- Ask natural-language questions about the scan results
|
||||
- Use slash commands (/export, /files, /model, /clear, /help)
|
||||
- Use slash commands (/export, /files, /score, /model, /clear, /help, /exit)
|
||||
- Ctrl+C to exit
|
||||
|
||||
The AI is given full scan context at the start of the conversation
|
||||
and remembers the entire chat history during the session.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
from rich.panel import Panel
|
||||
from rich.prompt import Prompt
|
||||
|
||||
from securelens.ai import call_ai
|
||||
from securelens.ai.prompts import chat_prompt
|
||||
from securelens.output import console, print_ai_response, print_info, print_error, print_success
|
||||
from securelens.output.exporters import save_json, save_markdown
|
||||
|
||||
console_out = Console()
|
||||
console = Console()
|
||||
|
||||
HELP_TEXT = """
|
||||
[bold cyan]Available commands:[/bold cyan]
|
||||
|
||||
[bold]/help[/bold] Show this help message
|
||||
[bold]/files[/bold] List files that were scanned
|
||||
[bold]/score[/bold] Show the security score
|
||||
[bold]/files[/bold] List files that were analyzed
|
||||
[bold]/score[/bold] Show the current security score
|
||||
[bold]/issues[/bold] Show all found issues (summary)
|
||||
[bold]/issues critical[/bold] Filter issues by severity (critical/high/medium/low)
|
||||
[bold]/export markdown[/bold] Save the report as a Markdown file
|
||||
[bold]/export json[/bold] Save the report as a JSON file
|
||||
[bold]/model <name>[/bold] Switch AI model (e.g. /model gpt-4o-mini)
|
||||
@@ -44,6 +45,7 @@ Or just type a question in plain English, e.g.:
|
||||
[dim]> How do I fix the SQL injection?[/dim]
|
||||
[dim]> What's the most critical issue?[/dim]
|
||||
[dim]> Show me all issues in auth.py[/dim]
|
||||
[dim]> Give me a step-by-step remediation plan[/dim]
|
||||
"""
|
||||
|
||||
|
||||
@@ -51,7 +53,7 @@ Or just type a question in plain English, e.g.:
|
||||
class ReplContext:
|
||||
target: str
|
||||
scan_result: object # LocalScanResult or WebScanResult
|
||||
target_type: str # "code" | "web"
|
||||
target_type: str # "code" | "web" | "github"
|
||||
api_key: str
|
||||
model: str
|
||||
conversation_history: list = field(default_factory=list)
|
||||
@@ -61,19 +63,21 @@ async def run_repl(ctx: ReplContext) -> None:
|
||||
"""
|
||||
Enter the interactive REPL. Blocks until the user exits.
|
||||
"""
|
||||
# Build initial scan context string (injected into every AI prompt)
|
||||
# Build scan context string once — injected into every AI prompt
|
||||
scan_ctx_str = _build_scan_context(ctx)
|
||||
|
||||
console_out.print()
|
||||
console_out.print("[bold cyan]💬 Ask a follow-up[/bold cyan] [dim](or press Ctrl+C / type /exit to quit)[/dim]")
|
||||
console_out.print("[dim]Type /help for available commands[/dim]")
|
||||
console_out.print()
|
||||
console.print()
|
||||
console.rule("[bold cyan] SecureLens AI Chat [/bold cyan]", style="cyan")
|
||||
console.print(
|
||||
"[dim]Ask anything about the scan results. "
|
||||
"Type [bold]/help[/bold] for commands, [bold]Ctrl+C[/bold] to exit.[/dim]\n"
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_input = _prompt_user()
|
||||
user_input = Prompt.ask("[bold cyan]>[/bold cyan]")
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
console_out.print("\n[dim]Goodbye![/dim]\n")
|
||||
console.print("\n[dim]Goodbye![/dim]\n")
|
||||
break
|
||||
|
||||
user_input = user_input.strip()
|
||||
@@ -89,44 +93,52 @@ async def run_repl(ctx: ReplContext) -> None:
|
||||
|
||||
# ── AI response ─────────────────────────────────────────────────────
|
||||
if not ctx.api_key:
|
||||
print_error("No API key configured. Run `securelens configure` to set one.")
|
||||
console.print(
|
||||
"\n [bold red]✗ No API key configured.[/bold red] "
|
||||
"Run [cyan]securelens configure[/cyan] to set one.\n"
|
||||
)
|
||||
continue
|
||||
|
||||
prompt = chat_prompt(ctx.target, scan_ctx_str, user_input)
|
||||
console_out.print("[dim] Thinking...[/dim]")
|
||||
response = await call_ai(
|
||||
prompt=prompt,
|
||||
api_key=ctx.api_key,
|
||||
model=ctx.model,
|
||||
temperature=0.5,
|
||||
conversation_history=ctx.conversation_history,
|
||||
)
|
||||
# Show a thinking indicator
|
||||
with console.status("[dim]Thinking...[/dim]", spinner="dots"):
|
||||
prompt = chat_prompt(ctx.target, scan_ctx_str, user_input)
|
||||
response = await call_ai(
|
||||
prompt=prompt,
|
||||
api_key=ctx.api_key,
|
||||
model=ctx.model,
|
||||
temperature=0.5,
|
||||
conversation_history=ctx.conversation_history,
|
||||
)
|
||||
|
||||
if response:
|
||||
# Save to history for multi-turn context
|
||||
# Append to history for multi-turn context (cap at 20 turns to avoid token bloat)
|
||||
ctx.conversation_history.append({"role": "user", "content": user_input})
|
||||
ctx.conversation_history.append({"role": "assistant", "content": response})
|
||||
print_ai_response(response)
|
||||
if len(ctx.conversation_history) > 40:
|
||||
ctx.conversation_history = ctx.conversation_history[-40:]
|
||||
|
||||
# Render AI response as Markdown (handles code blocks, bullets, headers)
|
||||
console.print()
|
||||
console.print(Panel(
|
||||
Markdown(response),
|
||||
border_style="dim cyan",
|
||||
padding=(0, 1),
|
||||
))
|
||||
console.print()
|
||||
else:
|
||||
print_error("No response from AI. Check your API key and network connection.")
|
||||
console.print(
|
||||
"\n [bold red]✗ No response from AI.[/bold red] "
|
||||
"Check your API key and network connection.\n"
|
||||
)
|
||||
|
||||
|
||||
def _prompt_user() -> str:
|
||||
"""Read a line from stdin with a styled prompt."""
|
||||
sys.stdout.write("[dim bold cyan]>[/dim bold cyan] ")
|
||||
sys.stdout.flush()
|
||||
# Use input() — rich.prompt not used here to keep it simple
|
||||
try:
|
||||
from rich.prompt import Prompt
|
||||
return Prompt.ask("[bold cyan]>[/bold cyan]")
|
||||
except Exception:
|
||||
return input("> ")
|
||||
|
||||
# ── Scan context builder ──────────────────────────────────────────────────────
|
||||
|
||||
def _build_scan_context(ctx: ReplContext) -> str:
|
||||
"""Serialize the scan result into a compact string for the AI context."""
|
||||
"""Serialize the scan result into a compact JSON string for AI context."""
|
||||
result = ctx.scan_result
|
||||
if ctx.target_type == "code":
|
||||
|
||||
if ctx.target_type in ("code", "github"):
|
||||
vulns = [
|
||||
{
|
||||
"file": v.file_path,
|
||||
@@ -146,6 +158,7 @@ def _build_scan_context(ctx: ReplContext) -> str:
|
||||
"vulnerabilities": vulns,
|
||||
"ai_summary": result.ai_summary,
|
||||
}, indent=2)
|
||||
|
||||
else: # web
|
||||
issues = [
|
||||
{"layer": i.layer, "severity": i.severity, "issue": i.issue, "fix": i.fix}
|
||||
@@ -162,57 +175,119 @@ def _build_scan_context(ctx: ReplContext) -> str:
|
||||
}, indent=2)
|
||||
|
||||
|
||||
# ── Slash command dispatcher ──────────────────────────────────────────────────
|
||||
|
||||
async def _handle_slash_command(cmd: str, ctx: ReplContext) -> bool:
|
||||
"""
|
||||
Process a slash command. Returns True if the REPL should exit.
|
||||
Handle a slash command. Returns True if the REPL should exit.
|
||||
"""
|
||||
parts = cmd.split()
|
||||
parts = cmd.strip().split(maxsplit=2)
|
||||
command = parts[0].lower()
|
||||
|
||||
if command == "/exit":
|
||||
console_out.print("\n[dim]Goodbye![/dim]\n")
|
||||
console.print("\n[dim]Goodbye![/dim]\n")
|
||||
return True
|
||||
|
||||
elif command == "/help":
|
||||
console_out.print(HELP_TEXT)
|
||||
console.print(HELP_TEXT)
|
||||
|
||||
elif command == "/clear":
|
||||
console_out.clear()
|
||||
console.clear()
|
||||
|
||||
elif command == "/files":
|
||||
result = ctx.scan_result
|
||||
if ctx.target_type == "code" and hasattr(result, "files_triaged"):
|
||||
console_out.print("\n[bold]Files analyzed:[/bold]")
|
||||
for f in result.files_triaged:
|
||||
console_out.print(f" [dim]• {f}[/dim]")
|
||||
console_out.print()
|
||||
if ctx.target_type in ("code", "github") and hasattr(result, "files_triaged"):
|
||||
if result.files_triaged:
|
||||
console.print("\n[bold]Files analyzed:[/bold]")
|
||||
for f in result.files_triaged:
|
||||
console.print(f" [dim]• {f}[/dim]")
|
||||
else:
|
||||
console.print("\n [dim]No files were analyzed.[/dim]")
|
||||
console.print()
|
||||
else:
|
||||
print_info("File list not available for web scans.")
|
||||
console.print("\n [dim]File list not available for web scans.[/dim]\n")
|
||||
|
||||
elif command == "/score":
|
||||
r = ctx.scan_result
|
||||
score = r.score
|
||||
grade = r.grade
|
||||
console_out.print(f"\n [bold]Score:[/bold] {score}/100 [bold]Grade:[/bold] {grade}\n")
|
||||
from securelens.output import GRADE_COLOR
|
||||
grade_color = GRADE_COLOR.get(r.grade, "white")
|
||||
console.print(
|
||||
f"\n Score: [{grade_color}]{r.score}/100 Grade: {r.grade}[/{grade_color}]\n"
|
||||
)
|
||||
|
||||
elif command == "/issues":
|
||||
severity_filter = parts[1].strip().lower() if len(parts) > 1 else None
|
||||
_print_issues_summary(ctx, severity_filter)
|
||||
|
||||
elif command == "/model":
|
||||
if len(parts) < 2:
|
||||
print_info(f"Current model: {ctx.model}")
|
||||
print_info("Usage: /model <model-name> e.g. /model gpt-4o-mini")
|
||||
console.print(f"\n [dim]Current model: {ctx.model}[/dim]")
|
||||
console.print(" [dim]Usage: /model <model-name> e.g. /model gpt-4o-mini[/dim]\n")
|
||||
else:
|
||||
ctx.model = parts[1]
|
||||
print_success(f"Model switched to: {ctx.model}")
|
||||
console.print(f"\n [bold green]✓ Model switched to: {ctx.model}[/bold green]\n")
|
||||
|
||||
elif command == "/export":
|
||||
fmt = parts[1].lower() if len(parts) > 1 else "markdown"
|
||||
target_type = "code" if ctx.target_type in ("code", "github") else "web"
|
||||
if fmt == "json":
|
||||
path = save_json(ctx.scan_result, ctx.target_type)
|
||||
print_success(f"JSON report saved to: {path}")
|
||||
path = save_json(ctx.scan_result, target_type)
|
||||
console.print(f"\n [bold green]✓ JSON report saved:[/bold green] [dim]{path}[/dim]\n")
|
||||
else:
|
||||
path = save_markdown(ctx.scan_result, ctx.target_type)
|
||||
print_success(f"Markdown report saved to: {path}")
|
||||
path = save_markdown(ctx.scan_result, target_type)
|
||||
console.print(f"\n [bold green]✓ Markdown report saved:[/bold green] [dim]{path}[/dim]\n")
|
||||
|
||||
else:
|
||||
print_error(f"Unknown command: {command}. Type /help for available commands.")
|
||||
console.print(
|
||||
f"\n [bold red]✗ Unknown command: {command}[/bold red] "
|
||||
"Type [cyan]/help[/cyan] for available commands.\n"
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _print_issues_summary(ctx: ReplContext, severity_filter: Optional[str] = None) -> None:
|
||||
"""Print a compact list of all issues, optionally filtered by severity."""
|
||||
result = ctx.scan_result
|
||||
from securelens.output import SEVERITY_COLOR
|
||||
|
||||
if ctx.target_type in ("code", "github"):
|
||||
issues = result.vulnerabilities
|
||||
if not issues:
|
||||
console.print("\n [bold green]✓ No vulnerabilities found.[/bold green]\n")
|
||||
return
|
||||
|
||||
filtered = issues
|
||||
if severity_filter:
|
||||
filtered = [v for v in issues if v.severity.lower() == severity_filter]
|
||||
if not filtered:
|
||||
console.print(f"\n [dim]No {severity_filter} issues found.[/dim]\n")
|
||||
return
|
||||
|
||||
console.print(f"\n [bold]{len(filtered)} issue(s):[/bold]")
|
||||
for i, v in enumerate(filtered, 1):
|
||||
color = SEVERITY_COLOR.get(v.severity, "white")
|
||||
loc = v.file_path
|
||||
if v.line_number:
|
||||
loc += f":{v.line_number}"
|
||||
console.print(f" [{color}][{i}] {v.severity}[/{color}] {v.issue} [dim]{loc}[/dim]")
|
||||
console.print()
|
||||
|
||||
else: # web
|
||||
issues = result.issues
|
||||
if not issues:
|
||||
console.print("\n [bold green]✓ No issues found.[/bold green]\n")
|
||||
return
|
||||
|
||||
filtered = issues
|
||||
if severity_filter:
|
||||
filtered = [i for i in issues if i.severity.lower() == severity_filter]
|
||||
if not filtered:
|
||||
console.print(f"\n [dim]No {severity_filter} issues found.[/dim]\n")
|
||||
return
|
||||
|
||||
console.print(f"\n [bold]{len(filtered)} issue(s):[/bold]")
|
||||
for i, issue in enumerate(filtered, 1):
|
||||
color = SEVERITY_COLOR.get(issue.severity, "white")
|
||||
console.print(f" [{color}][{i}] {issue.severity}[/{color}] {issue.issue} [dim]{issue.layer}[/dim]")
|
||||
console.print()
|
||||
|
||||
Reference in New Issue
Block a user