Security Panel MVP (#1660)
TODOs: - [x] Add documentation - [x] e2e tests: run security review, update knowledge, and fix issue - [x] more stringent risk rating <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces a new Security mode with a Security Review panel that runs reviews, edits rules, parses findings via IPC, and supports fixing issues, with tests and prompt/runtime support. > > - **UI/Preview Panel**: > - Add `security` preview mode to `previewModeAtom` and ActionHeader (Shield button). > - New `SecurityPanel` showing findings table (sorted by severity), run review, fix issue flow, and edit `SECURITY_RULES.md` dialog. > - Wire into `PreviewPanel` content switch. > - **Hooks**: > - `useSecurityReview(appId)`: fetch latest review via IPC. > - `useStreamChat`: add `onSettled` callback to invoke refreshes after streams. > - **IPC/Main**: > - `security_handlers`: `get-latest-security-review` parses `<dyad-security-finding>` from latest assistant message. > - Register handler in `ipc_host`; expose channel in `preload`. > - `ipc_client`: add `getLatestSecurityReview(appId)`. > - `chat_stream_handlers`: detect `/security-review`, use dedicated system prompt, optionally append `SECURITY_RULES.md`, suppress Supabase-not-available note in this mode. > - **Prompts**: > - Add `SECURITY_REVIEW_SYSTEM_PROMPT` with structured finding output. > - **Supabase**: > - Enhance schema query to include `rls_enabled`, split policy `using_clause`/`with_check_clause`. > - **E2E Tests**: > - New `security_review.spec.ts` plus snapshots and fixture findings; update test helper for `security` mode and findings table snapshot. > - Fake LLM server streams security findings for `/security-review` and increases batch size. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 5022d01e22a2dd929a968eeba0da592e0aeece01. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
This commit is contained in:
811
src/components/preview_panel/SecurityPanel.tsx
Normal file
811
src/components/preview_panel/SecurityPanel.tsx
Normal file
@@ -0,0 +1,811 @@
|
||||
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,
|
||||
} 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 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 <AlertTriangle className="h-4 w-4" />;
|
||||
case "high":
|
||||
return <AlertCircle className="h-4 w-4" />;
|
||||
case "medium":
|
||||
return <AlertCircle className="h-4 w-4" />;
|
||||
case "low":
|
||||
return <Info className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${getSeverityColor(level)} uppercase text-xs font-semibold flex items-center gap-1 w-fit`}
|
||||
>
|
||||
<span className="flex-shrink-0">{getSeverityIcon(level)}</span>
|
||||
<span>{level}</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function RunReviewButton({
|
||||
isRunning,
|
||||
onRun,
|
||||
}: {
|
||||
isRunning: boolean;
|
||||
onRun: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Button onClick={onRun} className="gap-2" disabled={isRunning}>
|
||||
{isRunning ? (
|
||||
<>
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="m4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Running Security Review...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Shield className="w-4 h-4" />
|
||||
Run Security Review
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewSummary({ data }: { data: SecurityReviewResult }) {
|
||||
const counts = data.findings.reduce(
|
||||
(acc, finding) => {
|
||||
acc[finding.level] = (acc[finding.level] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
const severityLevels: Array<SecurityFinding["level"]> = [
|
||||
"critical",
|
||||
"high",
|
||||
"medium",
|
||||
"low",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-1 mt-1">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Last reviewed {formatTimeAgo(data.timestamp)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
{severityLevels
|
||||
.filter((level) => counts[level] > 0)
|
||||
.map((level) => (
|
||||
<span key={level} className="flex items-center gap-1.5">
|
||||
<span className="flex-shrink-0">{getSeverityIcon(level)}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{counts[level]}
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 capitalize">
|
||||
{level}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SecurityHeader({
|
||||
isRunning,
|
||||
onRun,
|
||||
data,
|
||||
onOpenEditRules,
|
||||
}: {
|
||||
isRunning: boolean;
|
||||
onRun: () => void;
|
||||
data?: SecurityReviewResult | undefined;
|
||||
onOpenEditRules: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="sticky top-0 z-10 bg-background pt-3 pb-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
Security Review
|
||||
<Badge variant="secondary" className="uppercase tracking-wide">
|
||||
experimental
|
||||
</Badge>
|
||||
</h1>
|
||||
<div className="text-sm">
|
||||
<p>
|
||||
<a
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline cursor-pointer"
|
||||
onClick={() =>
|
||||
IpcClient.getInstance().openExternalUrl(
|
||||
"https://www.dyad.sh/docs/guides/security-review",
|
||||
)
|
||||
}
|
||||
>
|
||||
Open Security Review docs
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
{data && data.findings.length > 0 && <ReviewSummary data={data} />}
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Button variant="outline" onClick={onOpenEditRules}>
|
||||
<Pencil className="w-4 h-4" />
|
||||
Edit Security Rules
|
||||
</Button>
|
||||
<RunReviewButton isRunning={isRunning} onRun={onRun} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingView() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-6 h-6 text-blue-600 dark:text-blue-400 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="m4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mt-4">
|
||||
Loading...
|
||||
</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoAppSelectedView() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-4">
|
||||
<Shield className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
No App Selected
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 max-w-md">
|
||||
Select an app to run a security review
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunningReviewCard() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center mx-auto mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-blue-600 dark:text-blue-400 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="m4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Security review is running
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Results will be available soon.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function NoReviewCard({
|
||||
isRunning,
|
||||
onRun,
|
||||
}: {
|
||||
isRunning: boolean;
|
||||
onRun: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mx-auto mb-4">
|
||||
<Shield className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
No Security Review Found
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Run a security review to identify potential vulnerabilities in your
|
||||
application.
|
||||
</p>
|
||||
<RunReviewButton isRunning={isRunning} onRun={onRun} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function NoIssuesCard({ data }: { data?: SecurityReviewResult }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center mx-auto mb-4">
|
||||
<Shield className="w-8 h-8 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
No Security Issues Found
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Your application passed the security review with no issues detected.
|
||||
</p>
|
||||
{data && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500 mt-2">
|
||||
Last reviewed {formatTimeAgo(data.timestamp)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function FindingsTable({
|
||||
findings,
|
||||
onOpenDetails,
|
||||
onFix,
|
||||
fixingFindingKey,
|
||||
}: {
|
||||
findings: SecurityFinding[];
|
||||
onOpenDetails: (finding: SecurityFinding) => void;
|
||||
onFix: (finding: SecurityFinding) => void;
|
||||
fixingFindingKey?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="border rounded-lg overflow-hidden"
|
||||
data-testid="security-findings-table"
|
||||
>
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider w-24">
|
||||
Level
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
Issue
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider w-32">
|
||||
Action
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{[...findings]
|
||||
.sort(
|
||||
(a, b) => getSeverityOrder(a.level) - getSeverityOrder(b.level),
|
||||
)
|
||||
.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;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-4 align-top">
|
||||
<SeverityBadge level={finding.level} />
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div
|
||||
className="space-y-2 cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onOpenDetails(finding)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpenDetails(finding);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{finding.title}
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300 prose prose-sm dark:prose-invert max-w-none">
|
||||
<VanillaMarkdownParser content={displayDescription} />
|
||||
</div>
|
||||
{isLongDescription && (
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenDetails(finding);
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 py-0 gap-1"
|
||||
>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
Show more
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 align-top text-right">
|
||||
<Button
|
||||
onClick={() => onFix(finding)}
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="gap-2"
|
||||
disabled={isFixing}
|
||||
>
|
||||
{isFixing ? (
|
||||
<>
|
||||
<svg
|
||||
className="w-4 h-4 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="m4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Fixing Issue...
|
||||
</>
|
||||
) : (
|
||||
<>Fix Issue</>
|
||||
)}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FindingDetailsDialog({
|
||||
open,
|
||||
finding,
|
||||
onClose,
|
||||
onFix,
|
||||
fixingFindingKey,
|
||||
}: {
|
||||
open: boolean;
|
||||
finding: SecurityFinding | null;
|
||||
onClose: (open: boolean) => void;
|
||||
onFix: (finding: SecurityFinding) => void;
|
||||
fixingFindingKey?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[80vw] md:max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between gap-3 pr-4">
|
||||
<span className="truncate">{finding?.title}</span>
|
||||
{finding && <SeverityBadge level={finding.level} />}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300 prose prose-sm dark:prose-invert max-w-none break-words max-h-[60vh] overflow-auto">
|
||||
{finding && <VanillaMarkdownParser content={finding.description} />}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (finding) {
|
||||
onFix(finding);
|
||||
onClose(false);
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
finding ? fixingFindingKey === createFindingKey(finding) : false
|
||||
}
|
||||
>
|
||||
{finding && fixingFindingKey === createFindingKey(finding) ? (
|
||||
<>
|
||||
<svg
|
||||
className="w-4 h-4 animate-spin mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="m4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Fixing Issue...
|
||||
</>
|
||||
) : (
|
||||
<>Fix Issue</>
|
||||
)}
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
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<SecurityFinding | null>(
|
||||
null,
|
||||
);
|
||||
const [isEditRulesOpen, setIsEditRulesOpen] = useState(false);
|
||||
const [rulesContent, setRulesContent] = useState("");
|
||||
const [fixingFindingKey, setFixingFindingKey] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = 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]);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingView />;
|
||||
}
|
||||
|
||||
if (!selectedAppId) {
|
||||
return <NoAppSelectedView />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-y-auto">
|
||||
<div className="p-4 pt-0 space-y-4">
|
||||
<SecurityHeader
|
||||
isRunning={isRunningReview}
|
||||
onRun={handleRunSecurityReview}
|
||||
data={data}
|
||||
onOpenEditRules={() => {
|
||||
setIsEditRulesOpen(true);
|
||||
if (selectedAppId) {
|
||||
refetchRules();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{isRunningReview ? (
|
||||
<RunningReviewCard />
|
||||
) : error ? (
|
||||
<NoReviewCard
|
||||
isRunning={isRunningReview}
|
||||
onRun={handleRunSecurityReview}
|
||||
/>
|
||||
) : data && data.findings.length > 0 ? (
|
||||
<FindingsTable
|
||||
findings={data.findings}
|
||||
onOpenDetails={openFindingDetails}
|
||||
onFix={handleFixIssue}
|
||||
fixingFindingKey={fixingFindingKey}
|
||||
/>
|
||||
) : (
|
||||
<NoIssuesCard data={data} />
|
||||
)}
|
||||
<FindingDetailsDialog
|
||||
open={detailsOpen}
|
||||
finding={detailsFinding}
|
||||
onClose={setDetailsOpen}
|
||||
onFix={handleFixIssue}
|
||||
fixingFindingKey={fixingFindingKey}
|
||||
/>
|
||||
<Dialog open={isEditRulesOpen} onOpenChange={setIsEditRulesOpen}>
|
||||
<DialogContent className="sm:max-w-2xl md:max-w-3xl lg:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Security Rules</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
This allows you to add additional context about your project
|
||||
specifically for security reviews. This content is saved to the{" "}
|
||||
<code className="text-xs">SECURITY_RULES.md</code> file. This can
|
||||
help catch additional issues or avoid flagging issues that are not
|
||||
relevant for your app.
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<textarea
|
||||
className="w-full h-72 rounded-md border border-gray-300 dark:border-gray-700 bg-transparent p-3 font-mono text-sm outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={rulesContent}
|
||||
onChange={(e) => setRulesContent(e.target.value)}
|
||||
placeholder="# SECURITY_RULES.md\n\nDescribe relevant security context, accepted risks, non-issues, and environment details."
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
onClick={handleSaveRules}
|
||||
disabled={isSaving || isFetchingRules}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user