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
|
2026-06-12 19:10:58 +05:30
|
|
|
[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
|
|
|
|
|
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:
|
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,
|
|
|
|
|
)
|
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")
|
2026-06-12 19:10:58 +05:30
|
|
|
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()
|