import { useAtomValue, useSetAtom } from "jotai"; import { selectedAppIdAtom } from "@/atoms/appAtoms"; import { selectedChatIdAtom } from "@/atoms/chatAtoms"; import { useSecurityReview } from "@/hooks/useSecurityReview"; import { IpcClient } from "@/ipc/ipc_client"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogClose, } from "@/components/ui/dialog"; import { Shield, AlertTriangle, AlertCircle, Info, ChevronDown, Pencil, Wrench, } from "lucide-react"; import { useNavigate } from "@tanstack/react-router"; import { useStreamChat } from "@/hooks/useStreamChat"; import { showError } from "@/lib/toast"; import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; import type { SecurityFinding, SecurityReviewResult } from "@/ipc/ipc_types"; import { useState, useEffect } from "react"; import { VanillaMarkdownParser } from "@/components/chat/DyadMarkdownParser"; import { showSuccess, showWarning } from "@/lib/toast"; import { useLoadAppFile } from "@/hooks/useLoadAppFile"; import { useQueryClient } from "@tanstack/react-query"; const getSeverityColor = (level: SecurityFinding["level"]) => { switch (level) { case "critical": return "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 border-red-200 dark:border-red-800"; case "high": return "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300 border-orange-200 dark:border-orange-800"; case "medium": return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800"; case "low": return "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300 border-gray-200 dark:border-gray-800"; } }; const getSeverityIcon = (level: SecurityFinding["level"]) => { switch (level) { case "critical": return ; case "high": return ; case "medium": return ; case "low": return ; } }; const DESCRIPTION_PREVIEW_LENGTH = 150; const createFindingKey = (finding: { title: string; level: string; description: string; }): string => { return JSON.stringify({ title: finding.title, level: finding.level, description: finding.description, }); }; const formatTimeAgo = (input: string | number | Date): string => { const timestampMs = new Date(input).getTime(); const nowMs = Date.now(); const diffMs = Math.max(0, nowMs - timestampMs); const minutes = Math.floor(diffMs / 60000); if (minutes < 1) return "just now"; if (minutes < 60) { return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; } const hours = Math.floor(minutes / 60); if (hours < 24) { return `${hours} hour${hours === 1 ? "" : "s"} ago`; } const days = Math.floor(hours / 24); return `${days} day${days === 1 ? "" : "s"} ago`; }; const getSeverityOrder = (level: SecurityFinding["level"]): number => { switch (level) { case "critical": return 0; case "high": return 1; case "medium": return 2; case "low": return 3; default: return 4; } }; function SeverityBadge({ level }: { level: SecurityFinding["level"] }) { return ( {getSeverityIcon(level)} {level} ); } function RunReviewButton({ isRunning, onRun, }: { isRunning: boolean; onRun: () => void; }) { return ( ); } function ReviewSummary({ data }: { data: SecurityReviewResult }) { const counts = data.findings.reduce( (acc, finding) => { acc[finding.level] = (acc[finding.level] || 0) + 1; return acc; }, {} as Record, ); const severityLevels: Array = [ "critical", "high", "medium", "low", ]; return (
Last reviewed {formatTimeAgo(data.timestamp)}
{severityLevels .filter((level) => counts[level] > 0) .map((level) => ( {getSeverityIcon(level)} {counts[level]} {level} ))}
); } function SecurityHeader({ isRunning, onRun, data, onOpenEditRules, selectedCount, onFixSelected, isFixingSelected, }: { isRunning: boolean; onRun: () => void; data?: SecurityReviewResult | undefined; onOpenEditRules: () => void; selectedCount: number; onFixSelected: () => void; isFixingSelected: boolean; }) { const [isButtonVisible, setIsButtonVisible] = useState(false); const [shouldRender, setShouldRender] = useState(false); useEffect(() => { if (selectedCount > 0) { // Show immediately setShouldRender(true); // Trigger animation after render setTimeout(() => setIsButtonVisible(true), 10); } else { // Trigger exit animation setIsButtonVisible(false); // Hide after animation completes const timer = setTimeout(() => setShouldRender(false), 300); return () => clearTimeout(timer); } }, [selectedCount]); return (
); } function LoadingView() { return (

Loading...

); } function NoAppSelectedView() { return (

No App Selected

Select an app to run a security review

); } function RunningReviewCard() { return (

Security review is running

Results will be available soon.

); } function NoReviewCard({ isRunning, onRun, }: { isRunning: boolean; onRun: () => void; }) { return (

No Security Review Found

Run a security review to identify potential vulnerabilities in your application.

); } function NoIssuesCard({ data }: { data?: SecurityReviewResult }) { return (

No Security Issues Found

Your application passed the security review with no issues detected.

{data && (

Last reviewed {formatTimeAgo(data.timestamp)}

)}
); } function FindingsTable({ findings, onOpenDetails, onFix, fixingFindingKey, selectedFindings, onToggleSelection, onToggleSelectAll, }: { findings: SecurityFinding[]; onOpenDetails: (finding: SecurityFinding) => void; onFix: (finding: SecurityFinding) => void; fixingFindingKey?: string | null; selectedFindings: Set; onToggleSelection: (findingKey: string) => void; onToggleSelectAll: () => void; }) { const sortedFindings = [...findings].sort( (a, b) => getSeverityOrder(a.level) - getSeverityOrder(b.level), ); const allSelected = sortedFindings.length > 0 && sortedFindings.every((finding) => selectedFindings.has(createFindingKey(finding)), ); return (
{sortedFindings.map((finding, index) => { const isLongDescription = finding.description.length > DESCRIPTION_PREVIEW_LENGTH; const displayDescription = isLongDescription ? finding.description.substring(0, DESCRIPTION_PREVIEW_LENGTH) + "..." : finding.description; const findingKey = createFindingKey(finding); const isFixing = fixingFindingKey === findingKey; const isSelected = selectedFindings.has(findingKey); return ( ); })}
Level Issue Action
onToggleSelection(findingKey)} aria-label={`Select ${finding.title}`} onClick={(e) => e.stopPropagation()} />
onOpenDetails(finding)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onOpenDetails(finding); } }} >
{finding.title}
{isLongDescription && ( )}
); } function FindingDetailsDialog({ open, finding, onClose, onFix, fixingFindingKey, }: { open: boolean; finding: SecurityFinding | null; onClose: (open: boolean) => void; onFix: (finding: SecurityFinding) => void; fixingFindingKey?: string | null; }) { return ( {finding?.title} {finding && }
{finding && }
); } export const SecurityPanel = () => { const selectedAppId = useAtomValue(selectedAppIdAtom); const setSelectedChatId = useSetAtom(selectedChatIdAtom); const navigate = useNavigate(); const queryClient = useQueryClient(); const { streamMessage } = useStreamChat({ hasChatId: false }); const { data, isLoading, error, refetch } = useSecurityReview(selectedAppId); const [isRunningReview, setIsRunningReview] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false); const [detailsFinding, setDetailsFinding] = useState( null, ); const [isEditRulesOpen, setIsEditRulesOpen] = useState(false); const [rulesContent, setRulesContent] = useState(""); const [fixingFindingKey, setFixingFindingKey] = useState(null); const [isSaving, setIsSaving] = useState(false); const [selectedFindings, setSelectedFindings] = useState>( new Set(), ); const [isFixingSelected, setIsFixingSelected] = useState(false); const { content: fetchedRules, loading: isFetchingRules, refreshFile: refetchRules, } = useLoadAppFile( isEditRulesOpen && selectedAppId ? selectedAppId : null, isEditRulesOpen ? "SECURITY_RULES.md" : null, ); useEffect(() => { if (fetchedRules !== null) { setRulesContent(fetchedRules); } }, [fetchedRules]); // Clear selections when data changes (e.g., after a new review) useEffect(() => { setSelectedFindings(new Set()); }, [data]); const handleSaveRules = async () => { if (!selectedAppId) { showError("No app selected"); return; } try { setIsSaving(true); const ipcClient = IpcClient.getInstance(); const { warning } = await ipcClient.editAppFile( selectedAppId, "SECURITY_RULES.md", rulesContent, ); await queryClient.invalidateQueries({ queryKey: ["versions", selectedAppId], }); if (warning) { showWarning(warning); } else { showSuccess("Security rules saved"); } setIsEditRulesOpen(false); refetchRules(); } catch (err: any) { showError(`Failed to save security rules: ${err.message || err}`); } finally { setIsSaving(false); } }; const openFindingDetails = (finding: SecurityFinding) => { setDetailsFinding(finding); setDetailsOpen(true); }; const handleRunSecurityReview = async () => { if (!selectedAppId) { showError("No app selected"); return; } try { setIsRunningReview(true); // Create a new chat const chatId = await IpcClient.getInstance().createChat(selectedAppId); // Navigate to the new chat setSelectedChatId(chatId); await navigate({ to: "/chat", search: { id: chatId } }); // Stream the security review prompt await streamMessage({ prompt: "/security-review", chatId, onSettled: () => { refetch(); setIsRunningReview(false); }, }); } catch (err) { showError(`Failed to run security review: ${err}`); setIsRunningReview(false); } }; const handleFixIssue = async (finding: SecurityFinding) => { if (!selectedAppId) { showError("No app selected"); return; } try { const key = createFindingKey(finding); setFixingFindingKey(key); const chatId = await IpcClient.getInstance().createChat(selectedAppId); // Navigate to the new chat setSelectedChatId(chatId); await navigate({ to: "/chat", search: { id: chatId } }); const prompt = `Please fix the following security issue in a simple and effective way: **${finding.title}** (${finding.level} severity) ${finding.description}`; await streamMessage({ prompt, chatId, onSettled: () => { setFixingFindingKey(null); }, }); } catch (err) { showError(`Failed to create fix chat: ${err}`); setFixingFindingKey(null); } }; const handleToggleSelection = (findingKey: string) => { setSelectedFindings((prev) => { const newSet = new Set(prev); if (newSet.has(findingKey)) { newSet.delete(findingKey); } else { newSet.add(findingKey); } return newSet; }); }; const handleToggleSelectAll = () => { if (!data?.findings) return; const sortedFindings = [...data.findings].sort( (a, b) => getSeverityOrder(a.level) - getSeverityOrder(b.level), ); const allKeys = sortedFindings.map((finding) => createFindingKey(finding)); const allSelected = allKeys.every((key) => selectedFindings.has(key)); if (allSelected) { setSelectedFindings(new Set()); } else { setSelectedFindings(new Set(allKeys)); } }; const handleFixSelected = async () => { if (!selectedAppId || selectedFindings.size === 0 || !data?.findings) { showError("No issues selected"); return; } try { setIsFixingSelected(true); // Get the selected findings const findingsToFix = data.findings.filter((finding) => selectedFindings.has(createFindingKey(finding)), ); // Create a new chat const chatId = await IpcClient.getInstance().createChat(selectedAppId); // Navigate to the new chat setSelectedChatId(chatId); await navigate({ to: "/chat", search: { id: chatId } }); // Build a comprehensive prompt for all selected issues const issuesList = findingsToFix .map( (finding, index) => `${index + 1}. **${finding.title}** (${finding.level} severity)\n${finding.description}`, ) .join("\n\n"); const prompt = `Please fix the following ${findingsToFix.length} security issue${findingsToFix.length !== 1 ? "s" : ""} in a simple and effective way: ${issuesList}`; await streamMessage({ prompt, chatId, onSettled: () => { setIsFixingSelected(false); setSelectedFindings(new Set()); }, }); } catch (err) { showError(`Failed to create fix chat: ${err}`); setIsFixingSelected(false); } }; if (isLoading) { return ; } if (!selectedAppId) { return ; } return (
{ setIsEditRulesOpen(true); if (selectedAppId) { refetchRules(); } }} selectedCount={selectedFindings.size} onFixSelected={handleFixSelected} isFixingSelected={isFixingSelected} /> {isRunningReview ? ( ) : error ? ( ) : data && data.findings.length > 0 ? ( ) : ( )} Edit Security Rules
This allows you to add additional context about your project specifically for security reviews. This content is saved to the{" "} SECURITY_RULES.md file. This can help catch additional issues or avoid flagging issues that are not relevant for your app.