Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Deleting a scan asks for confirmation first, naming the scan it is about to remove. The delete control in the scan table and the one in the top bar's scan menu both removed a scan's output folder on a single click, and because the files are gone from disk with no copy kept, a mis-click could not be taken back. The prompt opens on Cancel, and a confirmed delete says so. Modal dialogs also hold the keyboard now: focus moves into the panel when one opens, Tab stays inside it, and it returns to whatever opened the dialog on close.

### Added

- A scanned AI model file is checked for whether loading it runs code. Pickle-format weights (`.pkl`, and the pickle inside a PyTorch archive or an `object`-dtype `.npz` member) are analyzed with picklescan, now installed in the base image, and the verdict feeds the file-security axis of the model risk assessment: a dangerous global reads `caution`, globals that need a human reads `review`, and a format that cannot execute code on load reads `ok`. BomLens reported this for models on HuggingFace by reading the Hub's own scan; a file that was never published had no such record and therefore no verdict at all. A clean result states its scope — it is a pickle analysis, not a malware scan — and a scan that could not run leaves no security axis rather than implying the file is safe.
Expand Down
90 changes: 38 additions & 52 deletions docker/web/frontend/src/components/FileViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";

import { Button } from "@/components/ui/button";
import { Modal } from "@/components/ui/dialog";
import { fileUrl } from "@/lib/api";

interface Props {
Expand All @@ -18,8 +19,7 @@ interface Props {
/**
* Lightweight modal artifact viewer. HTML reports render in an iframe (so the
* report's own styles apply); JSON is pretty-printed; text/markdown shown raw.
* No @radix-ui/react-dialog dependency — a focus-trapped overlay is enough for
* this single-purpose viewer.
* The overlay, focus handling and Escape come from the shared Modal.
*/
export function FileViewer({ name, scanId, onClose }: Props) {
const { t } = useTranslation();
Expand All @@ -42,14 +42,6 @@ export function FileViewer({ name, scanId, onClose }: Props) {
};
}, [name, scanId, isHtml]);

useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
if (name) window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [name, onClose]);

if (!name) return null;

let body = text;
Expand All @@ -62,50 +54,44 @@ export function FileViewer({ name, scanId, onClose }: Props) {
}

return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-label={name}
<Modal
open
onClose={onClose}
label={name}
className="h-[80vh] max-w-4xl"
>
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative z-10 flex h-[80vh] w-full max-w-4xl flex-col overflow-hidden rounded-xl border bg-card shadow-lg animate-fade-in">
<div className="flex items-center justify-between gap-3 border-b px-4 py-3">
<span className="truncate font-mono text-sm">{name}</span>
<div className="flex shrink-0 items-center gap-2">
<Button variant="outline" size="sm" asChild>
<a href={fileUrl(scanId, name)} download={name}>
<Download className="h-4 w-4" />
{t("result.download")}
</a>
</Button>
<Button
variant="ghost"
size="icon"
onClick={onClose}
aria-label={t("viewer.close")}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
{isHtml ? (
<iframe
title={name}
src={fileUrl(scanId, name)}
className="h-full w-full bg-white"
/>
) : (
<pre className="whitespace-pre-wrap break-all p-4 font-mono text-xs">
{body}
</pre>
)}
<div className="flex items-center justify-between gap-3 border-b px-4 py-3">
<span className="truncate font-mono text-sm">{name}</span>
<div className="flex shrink-0 items-center gap-2">
<Button variant="outline" size="sm" asChild>
<a href={fileUrl(scanId, name)} download={name}>
<Download className="h-4 w-4" />
{t("result.download")}
</a>
</Button>
<Button
variant="ghost"
size="icon"
onClick={onClose}
aria-label={t("viewer.close")}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div className="flex-1 overflow-auto">
{isHtml ? (
<iframe
title={name}
src={fileUrl(scanId, name)}
className="h-full w-full bg-white"
/>
) : (
<pre className="whitespace-pre-wrap break-all p-4 font-mono text-xs">
{body}
</pre>
)}
</div>
</Modal>
);
}
39 changes: 35 additions & 4 deletions docker/web/frontend/src/components/NextApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ProgressLog } from "./ProgressLog";
import { RecentScans } from "./RecentScans";
import { ResultSection } from "./ResultSections";
import { ScanRunning } from "./ScanRunning";
import { ConfirmDialog } from "./ui/dialog";
import {
deleteScan,
getCapabilities,
Expand All @@ -33,6 +34,7 @@ import {
} from "@/lib/nav";
import { homeHash, newHash, parseHash, scanHash } from "@/lib/route";
import { deriveScanContext, sectionCounts } from "@/lib/results";
import { useToast } from "@/lib/toast";

type Status = "idle" | "running" | "done" | "error";

Expand Down Expand Up @@ -91,6 +93,7 @@ function toRecentLink(s: RecentScan): RecentScanLink {
*/
export function NextApp() {
const { t } = useTranslation();
const { toast } = useToast();
const [status, setStatus] = useState<Status>("idle");
const [logs, setLogs] = useState<string[]>([]);
// The failure message surfaced on the Scan-running screen when a scan can't
Expand All @@ -110,6 +113,8 @@ export function NextApp() {
docker: true,
});
const [recent, setRecent] = useState<RecentScan[]>([]);
// The scan a delete button asked to remove, waiting on the confirm dialog.
const [pendingDelete, setPendingDelete] = useState<string | null>(null);
// A finished scan's config, parked here when the user hits "Re-scan" so the
// New scan form can seed itself from it. The form reads it once on mount and
// clears it, so a subsequent plain New scan starts blank.
Expand Down Expand Up @@ -248,10 +253,24 @@ export function NextApp() {
// Close the live scan stream if the app unmounts.
useEffect(() => () => streamRef.current?.close(), []);

// Delete a past scan (its artifacts) and refresh the Recent list.
const deleteRecent = (id: string) => {
// Deleting a scan removes its output files from disk, and the server keeps no
// copy — there is nothing to undo afterwards. So the guard goes in front: both
// delete controls (the Recent table and the top-bar menu) park the id here and
// the dialog is what actually calls the API.
const pendingScan = recent.find((s) => s.id === pendingDelete);
// Name the scan in the prompt so the user sees which one is going. Falls back
// to the id when the list hasn't caught up with the menu.
const pendingLabel = pendingScan
? [pendingScan.project, pendingScan.version].filter(Boolean).join(" ")
: (pendingDelete ?? "");

const confirmDelete = () => {
const id = pendingDelete;
setPendingDelete(null);
if (!id) return;
void deleteScan(id).then(() => {
void refreshRecent();
toast(t("recent.deleted"));
// If we're viewing the scan we just deleted, drop back to New scan.
if (loadedIdRef.current === id) window.location.hash = homeHash();
});
Expand Down Expand Up @@ -358,7 +377,7 @@ export function NextApp() {
activeSection={activeSection}
activeScanId={loadedIdRef.current}
recent={recentLinks}
onDeleteRecent={deleteRecent}
onDeleteRecent={setPendingDelete}
counts={counts}
showSections={Boolean(result)}
homeHref={homeHash()}
Expand All @@ -377,7 +396,7 @@ export function NextApp() {
<RecentScans
scans={recent}
newHref={newHash()}
onDelete={deleteRecent}
onDelete={setPendingDelete}
/>
) : (
<NewScan
Expand Down Expand Up @@ -475,6 +494,18 @@ export function NextApp() {
)}
</div>
)}

{/* Fixed-position overlay, so it renders the same wherever it sits in the
tree — kept here to cover both the home and the result screens. */}
<ConfirmDialog
open={pendingDelete !== null}
title={t("recent.confirmDeleteTitle")}
description={t("recent.confirmDeleteBody", { scan: pendingLabel })}
confirmLabel={t("recent.delete")}
destructive
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</AppShell>
);
}
Loading
Loading