mirror of
https://github.com/Rarebuffalo/securelens-backend.git
synced 2026-06-19 07:00:30 +00:00
219 lines
7.4 KiB
Python
219 lines
7.4 KiB
Python
"""
|
|
Interactive REPL
|
|
================
|
|
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)
|
|
- 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 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()
|
|
|
|
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]/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)
|
|
[bold]/clear[/bold] Clear the terminal
|
|
[bold]/exit[/bold] Exit the REPL
|
|
|
|
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]
|
|
"""
|
|
|
|
|
|
@dataclass
|
|
class ReplContext:
|
|
target: str
|
|
scan_result: object # LocalScanResult or WebScanResult
|
|
target_type: str # "code" | "web"
|
|
api_key: str
|
|
model: str
|
|
conversation_history: list = field(default_factory=list)
|
|
|
|
|
|
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)
|
|
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()
|
|
|
|
while True:
|
|
try:
|
|
user_input = _prompt_user()
|
|
except (KeyboardInterrupt, EOFError):
|
|
console_out.print("\n[dim]Goodbye![/dim]\n")
|
|
break
|
|
|
|
user_input = user_input.strip()
|
|
if not user_input:
|
|
continue
|
|
|
|
# ── Slash commands ──────────────────────────────────────────────────
|
|
if user_input.startswith("/"):
|
|
should_exit = await _handle_slash_command(user_input, ctx)
|
|
if should_exit:
|
|
break
|
|
continue
|
|
|
|
# ── AI response ─────────────────────────────────────────────────────
|
|
if not ctx.api_key:
|
|
print_error("No API key configured. Run `securelens configure` to set one.")
|
|
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,
|
|
)
|
|
|
|
if response:
|
|
# Save to history for multi-turn context
|
|
ctx.conversation_history.append({"role": "user", "content": user_input})
|
|
ctx.conversation_history.append({"role": "assistant", "content": response})
|
|
print_ai_response(response)
|
|
else:
|
|
print_error("No response from AI. Check your API key and network connection.")
|
|
|
|
|
|
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("> ")
|
|
|
|
|
|
def _build_scan_context(ctx: ReplContext) -> str:
|
|
"""Serialize the scan result into a compact string for the AI context."""
|
|
result = ctx.scan_result
|
|
if ctx.target_type == "code":
|
|
vulns = [
|
|
{
|
|
"file": v.file_path,
|
|
"line": v.line_number,
|
|
"severity": v.severity,
|
|
"issue": v.issue,
|
|
"explanation": v.explanation,
|
|
"fix": v.suggested_fix,
|
|
}
|
|
for v in result.vulnerabilities
|
|
]
|
|
return json.dumps({
|
|
"target": result.target,
|
|
"score": result.score,
|
|
"grade": result.grade,
|
|
"files_scanned": result.files_triaged,
|
|
"vulnerabilities": vulns,
|
|
"ai_summary": result.ai_summary,
|
|
}, indent=2)
|
|
else: # web
|
|
issues = [
|
|
{"layer": i.layer, "severity": i.severity, "issue": i.issue, "fix": i.fix}
|
|
for i in result.issues
|
|
]
|
|
return json.dumps({
|
|
"target": result.url,
|
|
"score": result.score,
|
|
"grade": result.grade,
|
|
"ssl_expiry_days": result.ssl_expiry_days,
|
|
"exposed_paths": result.exposed_paths,
|
|
"issues": issues,
|
|
"ai_summary": result.ai_summary,
|
|
}, indent=2)
|
|
|
|
|
|
async def _handle_slash_command(cmd: str, ctx: ReplContext) -> bool:
|
|
"""
|
|
Process a slash command. Returns True if the REPL should exit.
|
|
"""
|
|
parts = cmd.split()
|
|
command = parts[0].lower()
|
|
|
|
if command == "/exit":
|
|
console_out.print("\n[dim]Goodbye![/dim]\n")
|
|
return True
|
|
|
|
elif command == "/help":
|
|
console_out.print(HELP_TEXT)
|
|
|
|
elif command == "/clear":
|
|
console_out.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()
|
|
else:
|
|
print_info("File list not available for web scans.")
|
|
|
|
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")
|
|
|
|
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")
|
|
else:
|
|
ctx.model = parts[1]
|
|
print_success(f"Model switched to: {ctx.model}")
|
|
|
|
elif command == "/export":
|
|
fmt = parts[1].lower() if len(parts) > 1 else "markdown"
|
|
if fmt == "json":
|
|
path = save_json(ctx.scan_result, ctx.target_type)
|
|
print_success(f"JSON report saved to: {path}")
|
|
else:
|
|
path = save_markdown(ctx.scan_result, ctx.target_type)
|
|
print_success(f"Markdown report saved to: {path}")
|
|
|
|
else:
|
|
print_error(f"Unknown command: {command}. Type /help for available commands.")
|
|
|
|
return False
|