From 87b0c1fca66b895ff54e8a0edcc63de368b2b872 Mon Sep 17 00:00:00 2001 From: hhftechnologies Date: Sun, 5 Apr 2026 13:25:51 +0530 Subject: [PATCH] Add scan history export/delete and SBOM UI Frontend: add exportScanHistory/deleteScanHistory API calls, wired UI actions (export + delete buttons) in scan history table, and enhance SBOM dialog to fetch, parse and display SBOM components with export. Update route tree to remove trailing slashes. Hooks: add useDeleteScanHistory mutation and integrate exports. Backend: register new routes for exporting and deleting scan history; implement ExportScanHistory (CSV attachment) and DeleteScanHistory handlers and DB DeleteScanResult. Scanner: add gcWorker to purge old jobs, return copies from getters to avoid races, and remove unused Trivy SBOM helper. Config: change scanner config env parsing to use strconv.ParseBool with safer defaults and remove deprecated helper code/tests. Overall: enables CSV export and deletion of scan results, improves SBOM UX, tightens routing, and adds server-side cleanup and safety improvements. --- .../features/scanner/api/get-scan-history.ts | 32 +++++ .../scanner/components/sbom-dialog.tsx | 118 ++++++++++++++++-- .../scanner/components/scan-history-page.tsx | 42 ++++++- .../features/scanner/hooks/use-scan-query.ts | 12 ++ frontend/src/routeTree.gen.ts | 30 ++--- home/internal/api/router.go | 2 + home/internal/api/scan_handlers.go | 92 ++++++++++---- home/internal/api/scan_handlers_test.go | 62 --------- home/internal/config/config.go | 32 +++-- home/internal/scanner/db.go | 6 + home/internal/scanner/sbom.go | 56 +-------- home/internal/scanner/scanner.go | 50 +++++++- 12 files changed, 349 insertions(+), 185 deletions(-) diff --git a/frontend/src/features/scanner/api/get-scan-history.ts b/frontend/src/features/scanner/api/get-scan-history.ts index ecf497a4..e60b35ed 100644 --- a/frontend/src/features/scanner/api/get-scan-history.ts +++ b/frontend/src/features/scanner/api/get-scan-history.ts @@ -65,3 +65,35 @@ export async function getAutoScanStatus(): Promise { return response.json(); } + +export async function deleteScanHistory(id: string): Promise { + const response = await authenticatedFetch(`${HISTORY_ENDPOINT}/${id}`, { + method: "DELETE", + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `Request failed with status ${response.status}`); + } +} + +export async function exportScanHistory(id: string): Promise { + const response = await authenticatedFetch(`${HISTORY_ENDPOINT}/${id}/export`, { + method: "GET", + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `Request failed with status ${response.status}`); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `scan_${id}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); +} diff --git a/frontend/src/features/scanner/components/sbom-dialog.tsx b/frontend/src/features/scanner/components/sbom-dialog.tsx index 5ee71160..57456256 100644 --- a/frontend/src/features/scanner/components/sbom-dialog.tsx +++ b/frontend/src/features/scanner/components/sbom-dialog.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { DownloadIcon, FileTextIcon } from "lucide-react"; import { toast } from "sonner"; @@ -19,6 +19,14 @@ import { SelectValue, } from "@/components/ui/select"; import { Spinner } from "@/components/ui/spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { downloadSBOM } from "../api/generate-sbom"; import { useGenerateSBOM, useSBOMJob } from "../hooks/use-scan-query"; @@ -36,6 +44,7 @@ export function SBOMDialog({ isOpen, onOpenChange, imageRef, host }: SBOMDialogP const [jobId, setJobId] = useState(null); const [started, setStarted] = useState(false); const [downloading, setDownloading] = useState(false); + const [sbomData, setSbomData] = useState(null); const generateMutation = useGenerateSBOM(); const { data: sbomJob } = useSBOMJob(jobId, started); @@ -44,6 +53,40 @@ export function SBOMDialog({ isOpen, onOpenChange, imageRef, host }: SBOMDialogP const isComplete = sbomJob?.status === "complete"; const isFailed = sbomJob?.status === "failed"; + useEffect(() => { + if (isComplete && !sbomData && jobId) { + downloadSBOM(jobId) + .then((blob) => blob.text()) + .then((text) => JSON.parse(text)) + .then((json) => setSbomData(json)) + .catch(console.error); + } + }, [isComplete, sbomData, jobId]); + + const getSbomComponents = () => { + if (!sbomData) return []; + if (format === "cyclonedx-json" && sbomData.components) { + return sbomData.components.map((c: any) => ({ + name: c.name, + version: c.version, + type: c.type, + purl: c.purl, + })); + } + if (format === "spdx-json" && sbomData.packages) { + return sbomData.packages.map((p: any) => { + const purlRef = p.externalRefs?.find((r: any) => r.referenceType === "purl"); + return { + name: p.name, + version: p.versionInfo, + type: "package", + purl: purlRef ? purlRef.referenceLocator : "", + }; + }); + } + return []; + }; + const handleGenerate = async () => { try { const job = await generateMutation.mutateAsync({ imageRef, host, format }); @@ -78,13 +121,16 @@ export function SBOMDialog({ isOpen, onOpenChange, imageRef, host }: SBOMDialogP setJobId(null); setStarted(false); setDownloading(false); + setSbomData(null); } onOpenChange(open); }; + const components = getSbomComponents(); + return ( - + @@ -142,20 +188,68 @@ export function SBOMDialog({ isOpen, onOpenChange, imageRef, host }: SBOMDialogP ) : isComplete ? (
-
-

SBOM generated successfully

-

- Format: {format === "spdx-json" ? "SPDX" : "CycloneDX"} JSON -

+
+
+

SBOM Details

+

+ Format: {format === "spdx-json" ? "SPDX" : "CycloneDX"} JSON • {components.length} components found +

+
+
-
+ +
+
+ + + + Package + Version + Type + PURL + + + + {!sbomData ? ( + + + + + + ) : components.length === 0 ? ( + + + No components found. + + + ) : ( + components.map((c: any, i: number) => ( + + {c.name} + {c.version} + + + {c.type || "unknown"} + + + + {c.purl} + + + )) + )} + +
+
+
+ +
-
) : isFailed ? ( diff --git a/frontend/src/features/scanner/components/scan-history-page.tsx b/frontend/src/features/scanner/components/scan-history-page.tsx index 8a7a1a57..09c9c306 100644 --- a/frontend/src/features/scanner/components/scan-history-page.tsx +++ b/frontend/src/features/scanner/components/scan-history-page.tsx @@ -3,6 +3,8 @@ import { format } from "date-fns"; import { ChevronLeftIcon, ChevronRightIcon, + DownloadIcon, + Trash2Icon, HistoryIcon, SearchIcon, XIcon, @@ -33,7 +35,8 @@ import { DialogTitle, } from "@/components/ui/dialog"; -import { useScanHistory, useScanHistoryDetail, useScannedImages } from "../hooks/use-scan-query"; +import { useScanHistory, useScanHistoryDetail, useScannedImages, useDeleteScanHistory } from "../hooks/use-scan-query"; +import { exportScanHistory } from "../api/get-scan-history"; import { ScanResultsSummary } from "./scan-results-summary"; import { ScanResultsTable } from "./scan-results-table"; import type { HistoryQueryParams } from "../types"; @@ -61,6 +64,7 @@ export function ScanHistoryPage() { }); const { data: scannedImages } = useScannedImages(); const { data: detailResult, isLoading: isDetailLoading } = useScanHistoryDetail(selectedScanId); + const deleteMutation = useDeleteScanHistory(); const uniqueHosts = Array.from( new Set(scannedImages?.map((img) => img.host) ?? []) @@ -167,18 +171,19 @@ export function ScanHistoryPage() { Date {params.sort_by === "completed_at" && (params.sort_dir === "desc" ? "\u2193" : "\u2191")} Duration + Actions {isLoading ? ( - + Loading scan history... ) : !historyData?.results.length ? ( - + No scan history found @@ -205,6 +210,37 @@ export function ScanHistoryPage() { {(result.duration_ms / 1000).toFixed(1)}s + +
+ + +
+
)) )} diff --git a/frontend/src/features/scanner/hooks/use-scan-query.ts b/frontend/src/features/scanner/hooks/use-scan-query.ts index 3df5d3c3..a70579ec 100644 --- a/frontend/src/features/scanner/hooks/use-scan-query.ts +++ b/frontend/src/features/scanner/hooks/use-scan-query.ts @@ -15,6 +15,7 @@ import { getScanHistoryDetail, getScannedImages, getAutoScanStatus, + deleteScanHistory, } from "../api/get-scan-history"; import type { ScannerConfig, HistoryQueryParams } from "../types"; @@ -146,3 +147,14 @@ export function useAutoScanStatus() { staleTime: 15_000, }); } + +export function useDeleteScanHistory() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => deleteScanHistory(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["scanHistory"] }); + queryClient.invalidateQueries({ queryKey: ["scannedImages"] }); + }, + }); +} diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index cde72b85..30260f01 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -77,11 +77,11 @@ export interface FileRoutesByFullPath { '/login': typeof LoginRoute '/settings': typeof SettingsRoute '/demo/tanstack-query': typeof DemoTanstackQueryRoute - '/alerts/': typeof AlertsIndexRoute - '/images/': typeof ImagesIndexRoute - '/networks/': typeof NetworksIndexRoute - '/scan-history/': typeof ScanHistoryIndexRoute - '/stats/': typeof StatsIndexRoute + '/alerts': typeof AlertsIndexRoute + '/images': typeof ImagesIndexRoute + '/networks': typeof NetworksIndexRoute + '/scan-history': typeof ScanHistoryIndexRoute + '/stats': typeof StatsIndexRoute '/containers/$containerId/logs': typeof ContainersContainerIdLogsRoute } export interface FileRoutesByTo { @@ -116,11 +116,11 @@ export interface FileRouteTypes { | '/login' | '/settings' | '/demo/tanstack-query' - | '/alerts/' - | '/images/' - | '/networks/' - | '/scan-history/' - | '/stats/' + | '/alerts' + | '/images' + | '/networks' + | '/scan-history' + | '/stats' | '/containers/$containerId/logs' fileRoutesByTo: FileRoutesByTo to: @@ -187,35 +187,35 @@ declare module '@tanstack/react-router' { '/stats/': { id: '/stats/' path: '/stats' - fullPath: '/stats/' + fullPath: '/stats' preLoaderRoute: typeof StatsIndexRouteImport parentRoute: typeof rootRouteImport } '/scan-history/': { id: '/scan-history/' path: '/scan-history' - fullPath: '/scan-history/' + fullPath: '/scan-history' preLoaderRoute: typeof ScanHistoryIndexRouteImport parentRoute: typeof rootRouteImport } '/networks/': { id: '/networks/' path: '/networks' - fullPath: '/networks/' + fullPath: '/networks' preLoaderRoute: typeof NetworksIndexRouteImport parentRoute: typeof rootRouteImport } '/images/': { id: '/images/' path: '/images' - fullPath: '/images/' + fullPath: '/images' preLoaderRoute: typeof ImagesIndexRouteImport parentRoute: typeof rootRouteImport } '/alerts/': { id: '/alerts/' path: '/alerts' - fullPath: '/alerts/' + fullPath: '/alerts' preLoaderRoute: typeof AlertsIndexRouteImport parentRoute: typeof rootRouteImport } diff --git a/home/internal/api/router.go b/home/internal/api/router.go index 2fad4bfd..f9f6d609 100644 --- a/home/internal/api/router.go +++ b/home/internal/api/router.go @@ -221,6 +221,7 @@ func (ar *APIRouter) registerScanRoutes(r chi.Router) { r.Get("/scan/history", ar.scanHandlers.GetScanHistory) r.Get("/scan/history/images", ar.scanHandlers.GetScannedImages) r.Get("/scan/history/{id}", ar.scanHandlers.GetScanHistoryDetail) + r.Get("/scan/history/{id}/export", ar.scanHandlers.ExportScanHistory) // Auto-scan status r.Get("/scan/autoscan/status", ar.scanHandlers.GetAutoScanStatus) @@ -233,6 +234,7 @@ func (ar *APIRouter) registerScanRoutes(r chi.Router) { mutating.Post("/scan", ar.scanHandlers.StartScan) mutating.Post("/scan/bulk", ar.scanHandlers.StartBulkScan) mutating.Delete("/scan/jobs/{id}", ar.scanHandlers.CancelScanJob) + mutating.Delete("/scan/history/{id}", ar.scanHandlers.DeleteScanHistory) mutating.Post("/scan/sbom", ar.scanHandlers.StartSBOMGeneration) }) } diff --git a/home/internal/api/scan_handlers.go b/home/internal/api/scan_handlers.go index 8599a47e..ab1403a9 100644 --- a/home/internal/api/scan_handlers.go +++ b/home/internal/api/scan_handlers.go @@ -2,7 +2,9 @@ package api import ( "context" + "encoding/csv" "encoding/json" + "fmt" "log" "net/http" "os" @@ -319,7 +321,7 @@ func (h *ScanHandlers) UpdateScannerConfig(w http.ResponseWriter, r *http.Reques return } - validScanners := map[string]bool{"grype": true, "trivy": true, "syft": true} + validScanners := map[string]bool{"grype": true, "trivy": true} if req.DefaultScanner != "" && !validScanners[req.DefaultScanner] { http.Error(w, "invalid defaultScanner", http.StatusBadRequest) return @@ -391,30 +393,6 @@ func (h *ScanHandlers) TestScanNotification(w http.ResponseWriter, r *http.Reque }) } -// configToScannerConfig converts config.ScannerConfig to models.ScannerConfig -func configToScannerConfig(cfg *config.ScannerConfig) *models.ScannerConfig { - return &models.ScannerConfig{ - GrypeImage: cfg.GrypeImage, - TrivyImage: cfg.TrivyImage, - SyftImage: cfg.SyftImage, - DefaultScanner: models.ScannerType(cfg.DefaultScanner), - GrypeArgs: cfg.GrypeArgs, - TrivyArgs: cfg.TrivyArgs, - Notifications: models.NotificationConfig{ - DiscordWebhookURL: cfg.DiscordWebhookURL, - SlackWebhookURL: cfg.SlackWebhookURL, - OnScanComplete: cfg.NotifyOnComplete, - OnBulkComplete: cfg.NotifyOnBulk, - OnNewCVEs: cfg.NotifyOnNewCVEs, - MinSeverity: models.SeverityLevel(cfg.NotifyMinSeverity), - }, - AutoScan: models.AutoScanConfig{ - Enabled: cfg.AutoScanEnabled, - PollInterval: cfg.AutoScanPollInterval, - }, - ForceRescan: cfg.ForceRescan, - } -} // --- History Handlers --- @@ -526,6 +504,70 @@ func (h *ScanHandlers) GetAutoScanStatus(w http.ResponseWriter, r *http.Request) WriteJsonResponse(w, http.StatusOK, h.autoScanner.Status()) } +// ExportScanHistory returns a scan history detail in CSV format. +func (h *ScanHandlers) ExportScanHistory(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + http.Error(w, "missing scan id", http.StatusBadRequest) + return + } + + result, err := h.scanner.Store().DB().GetResultByID(id) + if err != nil { + log.Printf("Failed to map export for %s: %v", id, err) + http.Error(w, "failed to get scan result", http.StatusInternalServerError) + return + } + if result == nil { + http.Error(w, "scan result not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "text/csv") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment;filename=scan_%s.csv", id)) + + writer := csv.NewWriter(w) + defer writer.Flush() + + // Write header + writer.Write([]string{"Severity", "Package", "Version", "VulnerabilityID", "Description", "DataSource"}) + + sanitize := func(s string) string { + if len(s) > 0 && (s[0] == '=' || s[0] == '+' || s[0] == '-' || s[0] == '@') { + return "'" + s + } + return s + } + + for _, vuln := range result.Vulnerabilities { + writer.Write([]string{ + string(vuln.Severity), + sanitize(vuln.Package), + sanitize(vuln.InstalledVersion), + sanitize(vuln.ID), + sanitize(vuln.Description), + sanitize(vuln.DataSource), + }) + } +} + +// DeleteScanHistory deletes a scan history record from the database. +func (h *ScanHandlers) DeleteScanHistory(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + http.Error(w, "missing scan id", http.StatusBadRequest) + return + } + + err := h.scanner.Store().DB().DeleteScanResult(id) + if err != nil { + http.Error(w, "failed to delete scan result", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + // --- Helpers --- func (h *ScanHandlers) resolveImageID(host, imageRef string) string { diff --git a/home/internal/api/scan_handlers_test.go b/home/internal/api/scan_handlers_test.go index 63793905..0c401b6e 100644 --- a/home/internal/api/scan_handlers_test.go +++ b/home/internal/api/scan_handlers_test.go @@ -50,69 +50,7 @@ func chiContext(r *http.Request, params map[string]string) *http.Request { return r.WithContext(ctx) } -// ─── configToScannerConfig ──────────────────────────────────────────────────── -func TestConfigToScannerConfig(t *testing.T) { - src := &config.ScannerConfig{ - GrypeImage: "anchore/grype:v1", - TrivyImage: "aquasec/trivy:v1", - SyftImage: "anchore/syft:v1", - DefaultScanner: "trivy", - GrypeArgs: "--add-cpes-if-none", - TrivyArgs: "--scanners vuln", - DiscordWebhookURL: "https://discord.example.com", - SlackWebhookURL: "https://slack.example.com", - NotifyOnComplete: true, - NotifyOnBulk: false, - NotifyMinSeverity: "High", - } - - got := configToScannerConfig(src) - - if got.GrypeImage != src.GrypeImage { - t.Fatalf("GrypeImage: expected %q, got %q", src.GrypeImage, got.GrypeImage) - } - if got.TrivyImage != src.TrivyImage { - t.Fatalf("TrivyImage: expected %q, got %q", src.TrivyImage, got.TrivyImage) - } - if got.SyftImage != src.SyftImage { - t.Fatalf("SyftImage: expected %q, got %q", src.SyftImage, got.SyftImage) - } - if string(got.DefaultScanner) != src.DefaultScanner { - t.Fatalf("DefaultScanner: expected %q, got %q", src.DefaultScanner, got.DefaultScanner) - } - if got.GrypeArgs != src.GrypeArgs { - t.Fatalf("GrypeArgs: expected %q, got %q", src.GrypeArgs, got.GrypeArgs) - } - if got.TrivyArgs != src.TrivyArgs { - t.Fatalf("TrivyArgs: expected %q, got %q", src.TrivyArgs, got.TrivyArgs) - } - if got.Notifications.DiscordWebhookURL != src.DiscordWebhookURL { - t.Fatalf("DiscordWebhookURL: expected %q, got %q", src.DiscordWebhookURL, got.Notifications.DiscordWebhookURL) - } - if got.Notifications.SlackWebhookURL != src.SlackWebhookURL { - t.Fatalf("SlackWebhookURL: expected %q, got %q", src.SlackWebhookURL, got.Notifications.SlackWebhookURL) - } - if got.Notifications.OnScanComplete != src.NotifyOnComplete { - t.Fatalf("OnScanComplete: expected %v, got %v", src.NotifyOnComplete, got.Notifications.OnScanComplete) - } - if got.Notifications.OnBulkComplete != src.NotifyOnBulk { - t.Fatalf("OnBulkComplete: expected %v, got %v", src.NotifyOnBulk, got.Notifications.OnBulkComplete) - } - if string(got.Notifications.MinSeverity) != src.NotifyMinSeverity { - t.Fatalf("MinSeverity: expected %q, got %q", src.NotifyMinSeverity, got.Notifications.MinSeverity) - } -} - -func TestConfigToScannerConfigZeroValue(t *testing.T) { - got := configToScannerConfig(&config.ScannerConfig{}) - if got == nil { - t.Fatal("expected non-nil result for zero-value input") - } - if got.DefaultScanner != "" { - t.Fatalf("expected empty DefaultScanner, got %q", got.DefaultScanner) - } -} // ─── GetScanJobs ────────────────────────────────────────────────────────────── diff --git a/home/internal/config/config.go b/home/internal/config/config.go index d8ea56fc..87785fff 100644 --- a/home/internal/config/config.go +++ b/home/internal/config/config.go @@ -188,9 +188,9 @@ func parseScannerConfig() ScannerConfig { NotifyOnBulk: true, NotifyOnNewCVEs: true, NotifyMinSeverity: "High", - AutoScanEnabled: os.Getenv("SCANNER_AUTO_SCAN") == "true", + AutoScanEnabled: false, AutoScanPollInterval: 15, - ForceRescan: os.Getenv("SCANNER_FORCE_RESCAN") == "true", + ForceRescan: false, } if v := os.Getenv("SCANNER_GRYPE_IMAGE"); v != "" { @@ -211,14 +211,30 @@ func parseScannerConfig() ScannerConfig { if v := os.Getenv("SCANNER_TRIVY_ARGS"); v != "" { cfg.TrivyArgs = v } - if os.Getenv("SCANNER_NOTIFY_ON_COMPLETE") == "false" { - cfg.NotifyOnComplete = false + if v := os.Getenv("SCANNER_NOTIFY_ON_COMPLETE"); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + cfg.NotifyOnComplete = b + } } - if os.Getenv("SCANNER_NOTIFY_ON_BULK") == "false" { - cfg.NotifyOnBulk = false + if v := os.Getenv("SCANNER_NOTIFY_ON_BULK"); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + cfg.NotifyOnBulk = b + } } - if os.Getenv("SCANNER_NOTIFY_ON_NEW_CVES") == "false" { - cfg.NotifyOnNewCVEs = false + if v := os.Getenv("SCANNER_NOTIFY_ON_NEW_CVES"); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + cfg.NotifyOnNewCVEs = b + } + } + if v := os.Getenv("SCANNER_AUTO_SCAN"); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + cfg.AutoScanEnabled = b + } + } + if v := os.Getenv("SCANNER_FORCE_RESCAN"); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + cfg.ForceRescan = b + } } if v := os.Getenv("SCANNER_NOTIFY_MIN_SEVERITY"); v != "" { cfg.NotifyMinSeverity = v diff --git a/home/internal/scanner/db.go b/home/internal/scanner/db.go index d2001dd3..600f1227 100644 --- a/home/internal/scanner/db.go +++ b/home/internal/scanner/db.go @@ -286,6 +286,12 @@ func (s *ScanDB) GetResultByID(id string) (*models.ScanResult, error) { return &r, nil } +// DeleteScanResult removes a scan result and its vulnerabilities by ID. +func (s *ScanDB) DeleteScanResult(id string) error { + _, err := s.db.Exec(`DELETE FROM scan_results WHERE id = ?`, id) + return err +} + // GetPreviousResult returns the scan result immediately before the given scan ID // for the same image and host. func (s *ScanDB) GetPreviousResult(host, imageRef, beforeID string) (*models.ScanResult, error) { diff --git a/home/internal/scanner/sbom.go b/home/internal/scanner/sbom.go index 91f26ffe..e8d6c1fc 100644 --- a/home/internal/scanner/sbom.go +++ b/home/internal/scanner/sbom.go @@ -10,7 +10,7 @@ import ( "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/image" - "github.com/docker/docker/client" + "github.com/google/uuid" "github.com/hhftechnology/vps-monitor/internal/models" ) @@ -167,61 +167,7 @@ func buildSBOMCmd(imageRef string, format models.SBOMFormat) []string { return []string{imageRef, "-o", outputFormat} } -// RunSBOMWithTrivy generates an SBOM using Trivy instead of Syft. -func RunSBOMWithTrivy(ctx context.Context, dockerClient *client.Client, trivyImage, imageRef string, format models.SBOMFormat) ([]byte, error) { - outputFormat := "spdx-json" - if format == models.SBOMFormatCycloneDX { - outputFormat = "cyclonedx" - } - cmd := []string{"image", "--format", outputFormat, imageRef} - - pullReader, err := dockerClient.ImagePull(ctx, trivyImage, image.PullOptions{}) - if err != nil { - return nil, fmt.Errorf("failed to pull trivy image: %w", err) - } - io.Copy(io.Discard, pullReader) - pullReader.Close() - - resp, err := dockerClient.ContainerCreate(ctx, &container.Config{ - Image: trivyImage, - Cmd: cmd, - }, &container.HostConfig{ - Binds: []string{"/var/run/docker.sock:/var/run/docker.sock"}, - }, nil, nil, "") - if err != nil { - return nil, fmt.Errorf("failed to create trivy sbom container: %w", err) - } - containerID := resp.ID - defer dockerClient.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: true}) - - if err := dockerClient.ContainerStart(ctx, containerID, container.StartOptions{}); err != nil { - return nil, fmt.Errorf("failed to start trivy sbom container: %w", err) - } - - statusCh, errCh := dockerClient.ContainerWait(ctx, containerID, container.WaitConditionNotRunning) - select { - case err := <-errCh: - if err != nil { - return nil, fmt.Errorf("error waiting for trivy sbom: %w", err) - } - case status := <-statusCh: - if status.StatusCode != 0 { - logs, _ := getContainerLogs(ctx, dockerClient, containerID) - return nil, fmt.Errorf("trivy sbom exited with code %d: %s", status.StatusCode, logs) - } - case <-ctx.Done(): - return nil, ctx.Err() - } - - logReader, err := dockerClient.ContainerLogs(ctx, containerID, container.LogsOptions{ShowStdout: true}) - if err != nil { - return nil, fmt.Errorf("failed to read trivy sbom output: %w", err) - } - defer logReader.Close() - - return demuxDockerLogs(logReader) -} // getContainerLogs is defined in grype.go, avoid redeclaration by using the existing one. // demuxDockerLogs is defined in grype.go, shared across the package. diff --git a/home/internal/scanner/scanner.go b/home/internal/scanner/scanner.go index 6bffcf07..8c491c1d 100644 --- a/home/internal/scanner/scanner.go +++ b/home/internal/scanner/scanner.go @@ -46,9 +46,38 @@ func NewScannerService(registry *services.Registry, cfg *models.ScannerConfig, d cancels: make(map[string]context.CancelFunc), } s.config.Store(cfg) + + go s.gcWorker() + return s } +func (s *ScannerService) gcWorker() { + ticker := time.NewTicker(1 * time.Hour) + for range ticker.C { + now := time.Now().Unix() + s.mu.Lock() + for id, job := range s.jobs { + if (job.Status == models.ScanJobComplete || job.Status == models.ScanJobFailed || job.Status == models.ScanJobCancelled) && (now-job.CreatedAt > 24*3600) { + delete(s.jobs, id) + delete(s.cancels, id) + } + } + for id, state := range s.bulkJobs { + if (state.job.Status == models.ScanJobComplete || state.job.Status == models.ScanJobFailed || state.job.Status == models.ScanJobCancelled) && (now-state.job.CreatedAt > 24*3600) { + delete(s.bulkJobs, id) + delete(s.cancels, id) + } + } + for id, job := range s.sbomJobs { + if (job.Status == models.ScanJobComplete || job.Status == models.ScanJobFailed || job.Status == models.ScanJobCancelled || job.Status == models.ScanJobExpired) && (now-job.CreatedAt > 24*3600) { + delete(s.sbomJobs, id) + } + } + s.mu.Unlock() + } +} + // UpdateConfig updates the scanner configuration. func (s *ScannerService) UpdateConfig(cfg *models.ScannerConfig) { s.config.Store(cfg) @@ -175,7 +204,11 @@ func (s *ScannerService) StartBulkScan(scannerType models.ScannerType, hosts []s func (s *ScannerService) GetJob(id string) *models.ScanJob { s.mu.RLock() defer s.mu.RUnlock() - return s.jobs[id] + if job, ok := s.jobs[id]; ok { + copyJob := *job + return ©Job + } + return nil } // GetBulkJob returns a bulk scan job by ID. @@ -183,7 +216,8 @@ func (s *ScannerService) GetBulkJob(id string) *models.BulkScanJob { s.mu.RLock() defer s.mu.RUnlock() if state, ok := s.bulkJobs[id]; ok { - return state.job + copyJob := *state.job + return ©Job } return nil } @@ -195,7 +229,8 @@ func (s *ScannerService) GetJobs() []*models.ScanJob { jobs := make([]*models.ScanJob, 0, len(s.jobs)) for _, job := range s.jobs { - jobs = append(jobs, job) + copyJob := *job + jobs = append(jobs, ©Job) } return jobs } @@ -207,7 +242,8 @@ func (s *ScannerService) GetBulkJobs() []*models.BulkScanJob { jobs := make([]*models.BulkScanJob, 0, len(s.bulkJobs)) for _, state := range s.bulkJobs { - jobs = append(jobs, state.job) + copyJob := *state.job + jobs = append(jobs, ©Job) } return jobs } @@ -260,7 +296,11 @@ func (s *ScannerService) CancelJob(id string) bool { func (s *ScannerService) GetSBOMJob(id string) *models.SBOMJob { s.mu.RLock() defer s.mu.RUnlock() - return s.sbomJobs[id] + if job, ok := s.sbomJobs[id]; ok { + copyJob := *job + return ©Job + } + return nil } func (s *ScannerService) runScan(ctx context.Context, job *models.ScanJob, cancel context.CancelFunc) {