Files

301 lines
11 KiB
Python
Raw Permalink Normal View History

2026-05-15 12:54:58 +05:30
"""
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
2026-05-22 21:47:45 +05:30
- Use slash commands (/export, /files, /score, /model, /clear, /help, /exit)
2026-05-15 12:54:58 +05:30
- 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 json
from dataclasses import dataclass, field
from typing import Optional
from rich.console import Console
2026-05-22 21:47:45 +05:30
from rich.markdown import Markdown
from rich.panel import Panel
from rich.prompt import Prompt
2026-05-15 12:54:58 +05:30
from securelens.ai import call_ai
from securelens.ai.prompts import chat_prompt
from securelens.output.exporters import save_json, save_markdown
2026-05-22 21:47:45 +05:30
console = Console()
2026-05-15 12:54:58 +05:30
HELP_TEXT = """
[bold cyan]Available commands:[/bold cyan]
[bold]/help[/bold] Show this help message
2026-05-22 21:47:45 +05:30
[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)
2026-05-15 12:54:58 +05:30
[bold]/export markdown[/bold] Save the report as a Markdown file
[bold]/export json[/bold] Save the report as a JSON file
[bold]/export pdf[/bold] Save the report as a PDF file
2026-05-15 12:54:58 +05:30
[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]
2026-05-22 21:47:45 +05:30
[dim]> Give me a step-by-step remediation plan[/dim]
2026-05-15 12:54:58 +05:30
"""
@dataclass
class ReplContext:
target: str
scan_result: object # LocalScanResult or WebScanResult
2026-05-22 21:47:45 +05:30
target_type: str # "code" | "web" | "github"
2026-05-15 12:54:58 +05:30
api_key: str
model: str
api_base: Optional[str] = None
2026-05-15 12:54:58 +05:30
conversation_history: list = field(default_factory=list)
async def run_repl(ctx: ReplContext) -> None:
"""
Enter the interactive REPL. Blocks until the user exits.
"""
2026-05-22 21:47:45 +05:30
# Build scan context string once — injected into every AI prompt
2026-05-15 12:54:58 +05:30
scan_ctx_str = _build_scan_context(ctx)
2026-05-22 21:47:45 +05:30
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"
)
2026-05-15 12:54:58 +05:30
while True:
try:
2026-05-22 21:47:45 +05:30
user_input = Prompt.ask("[bold cyan]>[/bold cyan]")
2026-05-15 12:54:58 +05:30
except (KeyboardInterrupt, EOFError):
2026-05-22 21:47:45 +05:30
console.print("\n[dim]Goodbye![/dim]\n")
2026-05-15 12:54:58 +05:30
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 and not ctx.model.startswith("ollama/"):
2026-05-22 21:47:45 +05:30
console.print(
"\n [bold red]✗ No API key configured.[/bold red] "
"Run [cyan]securelens configure[/cyan] to set one.\n"
)
2026-05-15 12:54:58 +05:30
continue
2026-05-22 21:47:45 +05:30
# 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,
api_base=ctx.api_base,
2026-05-22 21:47:45 +05:30
)
2026-05-15 12:54:58 +05:30
if response:
2026-05-22 21:47:45 +05:30
# Append to history for multi-turn context (cap at 20 turns to avoid token bloat)
2026-05-15 12:54:58 +05:30
ctx.conversation_history.append({"role": "user", "content": user_input})
ctx.conversation_history.append({"role": "assistant", "content": response})
2026-05-22 21:47:45 +05:30
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()
2026-05-15 12:54:58 +05:30
else:
2026-05-22 21:47:45 +05:30
console.print(
"\n [bold red]✗ No response from AI.[/bold red] "
"Check your API key and network connection.\n"
)
2026-05-15 12:54:58 +05:30
2026-05-22 21:47:45 +05:30
# ── Scan context builder ──────────────────────────────────────────────────────
2026-05-15 12:54:58 +05:30
def _build_scan_context(ctx: ReplContext) -> str:
2026-05-22 21:47:45 +05:30
"""Serialize the scan result into a compact JSON string for AI context."""
2026-05-15 12:54:58 +05:30
result = ctx.scan_result
2026-05-22 21:47:45 +05:30
if ctx.target_type in ("code", "github"):
2026-05-15 12:54:58 +05:30
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)
2026-05-22 21:47:45 +05:30
2026-05-15 12:54:58 +05:30
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)
2026-05-22 21:47:45 +05:30
# ── Slash command dispatcher ──────────────────────────────────────────────────
2026-05-15 12:54:58 +05:30
async def _handle_slash_command(cmd: str, ctx: ReplContext) -> bool:
"""
2026-05-22 21:47:45 +05:30
Handle a slash command. Returns True if the REPL should exit.
2026-05-15 12:54:58 +05:30
"""
2026-05-22 21:47:45 +05:30
parts = cmd.strip().split(maxsplit=2)
2026-05-15 12:54:58 +05:30
command = parts[0].lower()
if command == "/exit":
2026-05-22 21:47:45 +05:30
console.print("\n[dim]Goodbye![/dim]\n")
2026-05-15 12:54:58 +05:30
return True
elif command == "/help":
2026-05-22 21:47:45 +05:30
console.print(HELP_TEXT)
2026-05-15 12:54:58 +05:30
elif command == "/clear":
2026-05-22 21:47:45 +05:30
console.clear()
2026-05-15 12:54:58 +05:30
elif command == "/files":
result = ctx.scan_result
2026-05-22 21:47:45 +05:30
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()
2026-05-15 12:54:58 +05:30
else:
2026-05-22 21:47:45 +05:30
console.print("\n [dim]File list not available for web scans.[/dim]\n")
2026-05-15 12:54:58 +05:30
elif command == "/score":
r = ctx.scan_result
2026-05-22 21:47:45 +05:30
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)
2026-05-15 12:54:58 +05:30
elif command == "/model":
if len(parts) < 2:
2026-05-22 21:47:45 +05:30
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")
2026-05-15 12:54:58 +05:30
else:
ctx.model = parts[1]
2026-05-22 21:47:45 +05:30
console.print(f"\n [bold green]✓ Model switched to: {ctx.model}[/bold green]\n")
2026-05-15 12:54:58 +05:30
elif command == "/export":
fmt = parts[1].lower() if len(parts) > 1 else "markdown"
2026-05-22 21:47:45 +05:30
target_type = "code" if ctx.target_type in ("code", "github") else "web"
2026-05-15 12:54:58 +05:30
if fmt == "json":
2026-05-22 21:47:45 +05:30
path = save_json(ctx.scan_result, target_type)
console.print(f"\n [bold green]✓ JSON report saved:[/bold green] [dim]{path}[/dim]\n")
elif fmt == "pdf":
from securelens.output.exporters import save_pdf
path = save_pdf(ctx.scan_result, target_type)
console.print(f"\n [bold green]✓ PDF report saved:[/bold green] [dim]{path}[/dim]\n")
2026-05-15 12:54:58 +05:30
else:
2026-05-22 21:47:45 +05:30
path = save_markdown(ctx.scan_result, target_type)
console.print(f"\n [bold green]✓ Markdown report saved:[/bold green] [dim]{path}[/dim]\n")
2026-05-15 12:54:58 +05:30
else:
2026-05-22 21:47:45 +05:30
console.print(
f"\n [bold red]✗ Unknown command: {command}[/bold red] "
"Type [cyan]/help[/cyan] for available commands.\n"
)
2026-05-15 12:54:58 +05:30
return False
2026-05-22 21:47:45 +05:30
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()