diff --git a/src/App.tsx b/src/App.tsx index 8c94709..367b02c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,9 @@ import { Toaster } from 'sonner' import { Layout } from './components/Layout' import { Dashboard } from './pages/Dashboard' import { Projects } from './pages/Projects' +import { ProjectDetail } from './pages/ProjectDetail' import { Images } from './pages/Images' +import { ImageDetail } from './pages/ImageDetail' import { Vulnerabilities } from './pages/Vulnerabilities' import { IgnoreRules } from './pages/IgnoreRules' import { ScanJobs } from './pages/ScanJobs' @@ -17,7 +19,9 @@ function App() { } /> } /> + } /> } /> + } /> } /> } /> } /> diff --git a/src/lib/api.ts b/src/lib/api.ts index fcf43a5..9dfbe31 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -10,6 +10,14 @@ export interface Project { is_active: boolean created_at: string updated_at: string + vulnerability_counts?: { + critical: number + high: number + medium: number + low: number + unspecified: number + total: number + } } export interface Image { @@ -99,11 +107,17 @@ class ApiClient { } // Projects - async getProjects(params?: { skip?: number; limit?: number; active_only?: boolean }): Promise { + async getProjects(params?: { + skip?: number; + limit?: number; + active_only?: boolean; + include_vulnerability_counts?: boolean; + }): Promise { const searchParams = new URLSearchParams() if (params?.skip !== undefined) searchParams.append('skip', params.skip.toString()) if (params?.limit !== undefined) searchParams.append('limit', params.limit.toString()) if (params?.active_only !== undefined) searchParams.append('active_only', params.active_only.toString()) + if (params?.include_vulnerability_counts !== undefined) searchParams.append('include_vulnerability_counts', params.include_vulnerability_counts.toString()) return this.request(`/projects?${searchParams}`) } @@ -112,6 +126,14 @@ class ApiClient { return this.request(`/projects/${id}`) } + async getProjectImages(id: number, params?: { skip?: number; limit?: number }): Promise { + const searchParams = new URLSearchParams() + if (params?.skip !== undefined) searchParams.append('skip', params.skip.toString()) + if (params?.limit !== undefined) searchParams.append('limit', params.limit.toString()) + + return this.request(`/projects/${id}/images?${searchParams}`) + } + // Images async getImages(params?: { skip?: number; limit?: number; active_only?: boolean }): Promise { const searchParams = new URLSearchParams() diff --git a/src/pages/ImageDetail.tsx b/src/pages/ImageDetail.tsx new file mode 100644 index 0000000..c5fbb0c --- /dev/null +++ b/src/pages/ImageDetail.tsx @@ -0,0 +1,334 @@ +import { useEffect, useState } from 'react' +import { useParams, Link } from 'react-router-dom' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { apiClient, Image, Vulnerability } from '@/lib/api' +import { + ArrowLeft, + Container, + Shield, + AlertTriangle, + RefreshCw, + Calendar, + ExternalLink +} from 'lucide-react' + +const severityOrder = { + critical: 0, + high: 1, + medium: 2, + low: 3, + unspecified: 4 +} + +export function ImageDetail() { + const { id } = useParams<{ id: string }>() + const [image, setImage] = useState(null) + const [vulnerabilities, setVulnerabilities] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + const fetchImageData = async () => { + if (!id) return + + try { + setIsLoading(true) + setError(null) + + // Fetch image details and vulnerabilities in parallel + const [imageData, vulnerabilitiesData] = await Promise.all([ + apiClient.getImage(parseInt(id)), + apiClient.getImageVulnerabilities(parseInt(id)) + ]) + + setImage(imageData) + + // Sort vulnerabilities by severity + const sortedVulnerabilities = vulnerabilitiesData.sort((a, b) => { + const severityA = severityOrder[a.severity as keyof typeof severityOrder] ?? 5 + const severityB = severityOrder[b.severity as keyof typeof severityOrder] ?? 5 + return severityA - severityB + }) + + setVulnerabilities(sortedVulnerabilities) + + } catch (error) { + console.error('Failed to fetch image data:', error) + setError('Failed to load image information') + } finally { + setIsLoading(false) + } + } + + useEffect(() => { + fetchImageData() + }, [id]) + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (error || !image) { + return ( +
+

{error || 'Image not found'}

+ + + +
+ ) + } + + const vulnerabilityCounts = vulnerabilities.reduce((acc, vuln) => { + acc[vuln.severity] = (acc[vuln.severity] || 0) + 1 + return acc + }, {} as Record) + + const formatCVSSScore = (score: string | null): string => { + if (!score) return 'N/A' + const numScore = parseFloat(score) + if (isNaN(numScore)) return score + return numScore.toFixed(1) + } + + const getCVSSColor = (score: string | null): string => { + if (!score) return 'text-gray-500' + const numScore = parseFloat(score) + if (isNaN(numScore)) return 'text-gray-500' + + if (numScore >= 9.0) return 'text-red-600 font-bold' + if (numScore >= 7.0) return 'text-orange-600 font-semibold' + if (numScore >= 4.0) return 'text-yellow-600' + if (numScore > 0) return 'text-blue-600' + return 'text-gray-500' + } + + return ( +
+ {/* Header */} +
+
+ + + +
+

{image.image_name}

+

{image.full_image_name}

+
+
+
+ + {image.is_active ? "Active" : "Inactive"} + +
+
+ + {/* Image Information */} +
+ + + Tag + + + +
{image.tag || 'latest'}
+
+
+ + + + Registry + + + +
{image.registry || 'Docker Hub'}
+
+
+ + + + Usage Count + + + +
{image.usage_count || 0}
+

+ Files using this image +

+
+
+ + + + Last Seen + + + +
+ {new Date(image.last_seen).toLocaleString()} +
+
+
+
+ + {/* Vulnerability Summary */} + + + + + Vulnerability Summary + + + Security vulnerabilities found in this Docker image + + + + {vulnerabilities.length > 0 ? ( +
+
+
+ {vulnerabilityCounts.critical || 0} +
+ Critical +
+
+
+ {vulnerabilityCounts.high || 0} +
+ High +
+
+
+ {vulnerabilityCounts.medium || 0} +
+ Medium +
+
+
+ {vulnerabilityCounts.low || 0} +
+ Low +
+
+
+ {vulnerabilityCounts.unspecified || 0} +
+ Unspecified +
+
+ ) : ( +
+ No vulnerabilities found +
+ )} +
+
+ + {/* Vulnerabilities List */} + + + + + Vulnerability Details + + + Detailed list of all vulnerabilities found in this image + + + + {vulnerabilities.length > 0 ? ( + + + + CVE ID + Severity + CVSS Score + Title + Fixed Version + Published + Scan Job + + + + {vulnerabilities.map((vulnerability) => ( + + + + {vulnerability.vulnerability_id} + + + + + {vulnerability.severity.charAt(0).toUpperCase() + vulnerability.severity.slice(1)} + + + + + {formatCVSSScore(vulnerability.cvss_score)} + + + +
+
+ {vulnerability.title || 'No title available'} +
+ {vulnerability.description && ( +
+ {vulnerability.description} +
+ )} +
+
+ + {vulnerability.fixed_version ? ( + {vulnerability.fixed_version} + ) : ( + Not available + )} + + + {vulnerability.published_date + ? new Date(vulnerability.published_date).toLocaleDateString() + : 'Unknown' + } + + + {vulnerability.scan_job_id ? ( + + #{vulnerability.scan_job_id} + + ) : ( + - + )} + +
+ ))} +
+
+ ) : ( +
+ No vulnerabilities found for this image +
+ )} +
+
+
+ ) +} \ No newline at end of file diff --git a/src/pages/ProjectDetail.tsx b/src/pages/ProjectDetail.tsx new file mode 100644 index 0000000..3a6256c --- /dev/null +++ b/src/pages/ProjectDetail.tsx @@ -0,0 +1,385 @@ +import { useEffect, useState } from 'react' +import { useParams, Link } from 'react-router-dom' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { apiClient, Project, Image } from '@/lib/api' +import { + ArrowLeft, + ExternalLink, + Container, + Shield, + AlertTriangle, + RefreshCw, + Calendar, + GitBranch +} from 'lucide-react' + +interface ImageWithVulnerabilities extends Image { + vulnerability_counts: { + critical: number + high: number + medium: number + low: number + unspecified: number + total: number + } +} + +interface ProjectVulnerabilitySummary { + critical: number + high: number + medium: number + low: number + unspecified: number + total: number + last_scan_date: string | null +} + +const severityColors = { + critical: 'bg-red-100 text-red-800 border-red-200', + high: 'bg-orange-100 text-orange-800 border-orange-200', + medium: 'bg-yellow-100 text-yellow-800 border-yellow-200', + low: 'bg-blue-100 text-blue-800 border-blue-200', + unspecified: 'bg-gray-100 text-gray-800 border-gray-200', +} + +export function ProjectDetail() { + const { id } = useParams<{ id: string }>() + const [project, setProject] = useState(null) + const [images, setImages] = useState([]) + const [vulnerabilitySummary, setVulnerabilitySummary] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + const fetchProjectData = async () => { + if (!id) return + + try { + setIsLoading(true) + setError(null) + + // Fetch project details + const projectData = await apiClient.getProject(parseInt(id)) + setProject(projectData) + + // Fetch project images + const imagesData = await apiClient.getProjectImages(parseInt(id)) + + // Fetch vulnerability counts for each image + const imagesWithVulnerabilities: ImageWithVulnerabilities[] = [] + let totalCritical = 0, totalHigh = 0, totalMedium = 0, totalLow = 0, totalUnspecified = 0 + + for (const image of imagesData) { + try { + const vulnerabilities = await apiClient.getImageVulnerabilities(image.id) + + const counts = { + critical: vulnerabilities.filter(v => v.severity === 'critical').length, + high: vulnerabilities.filter(v => v.severity === 'high').length, + medium: vulnerabilities.filter(v => v.severity === 'medium').length, + low: vulnerabilities.filter(v => v.severity === 'low').length, + unspecified: vulnerabilities.filter(v => v.severity === 'unspecified').length, + total: vulnerabilities.length + } + + // Add to project totals + totalCritical += counts.critical + totalHigh += counts.high + totalMedium += counts.medium + totalLow += counts.low + totalUnspecified += counts.unspecified + + imagesWithVulnerabilities.push({ + ...image, + vulnerability_counts: counts + }) + } catch (error) { + console.error(`Failed to fetch vulnerabilities for image ${image.id}:`, error) + // Add image with zero counts if vulnerability fetch fails + imagesWithVulnerabilities.push({ + ...image, + vulnerability_counts: { + critical: 0, + high: 0, + medium: 0, + low: 0, + unspecified: 0, + total: 0 + } + }) + } + } + + setImages(imagesWithVulnerabilities) + + // Set vulnerability summary + setVulnerabilitySummary({ + critical: totalCritical, + high: totalHigh, + medium: totalMedium, + low: totalLow, + unspecified: totalUnspecified, + total: totalCritical + totalHigh + totalMedium + totalLow + totalUnspecified, + last_scan_date: projectData.last_scanned + }) + + } catch (error) { + console.error('Failed to fetch project data:', error) + setError('Failed to load project information') + } finally { + setIsLoading(false) + } + } + + useEffect(() => { + fetchProjectData() + }, [id]) + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (error || !project) { + return ( +
+

{error || 'Project not found'}

+ + + +
+ ) + } + + return ( +
+ {/* Header */} +
+
+ + + +
+

{project.name}

+

{project.path}

+
+
+ +
+ + {/* Project Information */} +
+ + + Project Status + + + +
+
+ Status: + + {project.is_active ? "Active" : "Inactive"} + +
+
+ GitLab ID: + #{project.gitlab_id} +
+
+
+
+ + + + Last Scan + + + +
+ {project.last_scanned + ? new Date(project.last_scanned).toLocaleString() + : 'Never scanned' + } +
+
+
+ + + + Docker Images + + + +
{images.length}
+

+ Active images found +

+
+
+
+ + {/* Vulnerability Summary */} + {vulnerabilitySummary && ( + + + + + Vulnerability Summary + + + Security vulnerabilities found across all images in this project + + + + {vulnerabilitySummary.total > 0 ? ( +
+
+
{vulnerabilitySummary.critical}
+ Critical +
+
+
{vulnerabilitySummary.high}
+ High +
+
+
{vulnerabilitySummary.medium}
+ Medium +
+
+
{vulnerabilitySummary.low}
+ Low +
+
+
{vulnerabilitySummary.unspecified}
+ Unspecified +
+
+ ) : ( +
+ No vulnerabilities found or project not yet scanned +
+ )} +
+
+ )} + + {/* Images List */} + + + + + Docker Images + + + Images found in this project with vulnerability counts + + + + {images.length > 0 ? ( + + + + Image + Tag + Registry + Usage Count + Critical + High + Medium + Low + Total + Last Seen + + + + {images.map((image) => ( + + + + {image.image_name} + + + + {image.tag || 'latest'} + + + {image.registry || 'Docker Hub'} + + + {image.usage_count || 0} + + + {image.vulnerability_counts.critical > 0 ? ( + {image.vulnerability_counts.critical} + ) : ( + 0 + )} + + + {image.vulnerability_counts.high > 0 ? ( + {image.vulnerability_counts.high} + ) : ( + 0 + )} + + + {image.vulnerability_counts.medium > 0 ? ( + {image.vulnerability_counts.medium} + ) : ( + 0 + )} + + + {image.vulnerability_counts.low > 0 ? ( + {image.vulnerability_counts.low} + ) : ( + 0 + )} + + + {image.vulnerability_counts.total} + + + {new Date(image.last_seen).toLocaleDateString()} + + + ))} + +
+ ) : ( +
+ No Docker images found in this project +
+ )} +
+
+
+ ) +} \ No newline at end of file diff --git a/src/pages/Projects.tsx b/src/pages/Projects.tsx index 6c2d185..a3103f7 100644 --- a/src/pages/Projects.tsx +++ b/src/pages/Projects.tsx @@ -1,9 +1,11 @@ import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { apiClient, Project } from '@/lib/api' -import { ExternalLink, RefreshCw } from 'lucide-react' +import { ExternalLink, RefreshCw, Eye } from 'lucide-react' export function Projects() { const [projects, setProjects] = useState([]) @@ -11,7 +13,7 @@ export function Projects() { const fetchProjects = async () => { try { - const data = await apiClient.getProjects() + const data = await apiClient.getProjects({ include_vulnerability_counts: true }) setProjects(data) } catch (error) { console.error('Failed to fetch projects:', error) @@ -37,7 +39,7 @@ export function Projects() {

Projects

- GitLab projects being monitored for Docker images + GitLab projects being monitored for Docker images with vulnerability counts from latest scans

@@ -45,7 +47,7 @@ export function Projects() { Project List - All projects discovered in your GitLab instance + All projects discovered in your GitLab instance with vulnerability summaries @@ -56,6 +58,11 @@ export function Projects() { Path Status Last Scanned + Critical + High + Medium + Low + Total Actions @@ -63,7 +70,12 @@ export function Projects() { {projects.map((project) => ( - {project.name} + + {project.name} + @@ -82,15 +94,59 @@ export function Projects() { } - - - View - + {project.vulnerability_counts?.critical ? ( + {project.vulnerability_counts.critical} + ) : ( + 0 + )} + + + {project.vulnerability_counts?.high ? ( + {project.vulnerability_counts.high} + ) : ( + 0 + )} + + + {project.vulnerability_counts?.medium ? ( + {project.vulnerability_counts.medium} + ) : ( + 0 + )} + + + {project.vulnerability_counts?.low ? ( + {project.vulnerability_counts.low} + ) : ( + 0 + )} + + + {project.vulnerability_counts?.total ? ( + {project.vulnerability_counts.total} + ) : ( + 0 + )} + + +
+ + + + + + +
))}