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.
This commit is contained in:
hhftechnologies 2026-04-05 13:25:51 +05:30
parent bf9f1fe3d7
commit 87b0c1fca6
12 changed files with 349 additions and 185 deletions

View file

@ -65,3 +65,35 @@ export async function getAutoScanStatus(): Promise<AutoScanStatus> {
return response.json();
}
export async function deleteScanHistory(id: string): Promise<void> {
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<void> {
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);
}

View file

@ -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<string | null>(null);
const [started, setStarted] = useState(false);
const [downloading, setDownloading] = useState(false);
const [sbomData, setSbomData] = useState<any>(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 (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogContent className={isComplete ? "max-w-4xl max-h-[85vh] overflow-y-auto" : "max-w-md"}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileTextIcon className="size-5" />
@ -142,20 +188,68 @@ export function SBOMDialog({ isOpen, onOpenChange, imageRef, host }: SBOMDialogP
</div>
) : isComplete ? (
<div className="space-y-4">
<div className="rounded-md bg-green-50 dark:bg-green-950 p-4">
<p className="font-medium text-green-700 dark:text-green-400">SBOM generated successfully</p>
<p className="text-sm text-muted-foreground mt-1">
Format: {format === "spdx-json" ? "SPDX" : "CycloneDX"} JSON
</p>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-green-700 dark:text-green-400">SBOM Details</p>
<p className="text-sm text-muted-foreground">
Format: {format === "spdx-json" ? "SPDX" : "CycloneDX"} JSON &bull; {components.length} components found
</p>
</div>
<Button onClick={handleDownload} disabled={downloading} size="sm">
<DownloadIcon className="mr-2 size-4" />
{downloading ? "Downloading..." : "Export"}
</Button>
</div>
<div className="flex justify-end gap-2">
<div className="border rounded-md overflow-hidden">
<div className="max-h-[50vh] overflow-y-auto">
<Table>
<TableHeader className="bg-muted/50 sticky top-0">
<TableRow>
<TableHead>Package</TableHead>
<TableHead>Version</TableHead>
<TableHead>Type</TableHead>
<TableHead>PURL</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!sbomData ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8">
<Spinner className="size-5 mx-auto" />
</TableCell>
</TableRow>
) : components.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
No components found.
</TableCell>
</TableRow>
) : (
components.map((c: any, i: number) => (
<TableRow key={i}>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell>{c.version}</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{c.type || "unknown"}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground break-all">
{c.purl}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => handleClose(false)}>
Close
</Button>
<Button onClick={handleDownload} disabled={downloading}>
<DownloadIcon className="mr-2 size-4" />
{downloading ? "Downloading..." : "Download SBOM"}
</Button>
</div>
</div>
) : isFailed ? (

View file

@ -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")}
</TableHead>
<TableHead>Duration</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
Loading scan history...
</TableCell>
</TableRow>
) : !historyData?.results.length ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
No scan history found
</TableCell>
</TableRow>
@ -205,6 +210,37 @@ export function ScanHistoryPage() {
<TableCell className="text-sm text-muted-foreground">
{(result.duration_ms / 1000).toFixed(1)}s
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="icon"
title="Export CSV"
onClick={(e) => {
e.stopPropagation();
exportScanHistory(result.id).catch((err) => {
console.error("Failed to export:", err);
});
}}
>
<DownloadIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Delete"
disabled={deleteMutation.isPending}
onClick={(e) => {
e.stopPropagation();
if (confirm("Are you sure you want to delete this scan result?")) {
deleteMutation.mutate(result.id);
}
}}
>
<Trash2Icon className="size-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}

View file

@ -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"] });
},
});
}

View file

@ -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
}

View file

@ -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)
})
}

View file

@ -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 {

View file

@ -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 ──────────────────────────────────────────────────────────────

View file

@ -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

View file

@ -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) {

View file

@ -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.

View file

@ -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 &copyJob
}
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 &copyJob
}
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, &copyJob)
}
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, &copyJob)
}
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 &copyJob
}
return nil
}
func (s *ScannerService) runScan(ctx context.Context, job *models.ScanJob, cancel context.CancelFunc) {