mirror of
https://github.com/hhftechnology/vps-monitor.git
synced 2026-04-28 03:29:55 +00:00
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:
parent
bf9f1fe3d7
commit
87b0c1fca6
12 changed files with 349 additions and 185 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 • {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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue