feat: consolidate security audit UI (#2349)

This commit is contained in:
Patrick Erichsen
2026-05-20 15:33:35 -07:00
committed by GitHub
parent 5be50db7ec
commit 4e4d5c88d1
34 changed files with 1895 additions and 1646 deletions
@@ -17,8 +17,9 @@ paths:
- src/components/DetailSecuritySummary.tsx
- src/components/MarkdownPreview.tsx
- src/components/PackageSourceChooser.tsx
- src/components/SecurityScannerPage.tsx
- src/components/SecurityAuditPage.tsx
- src/components/SkillSecurityScanResults.tsx
- src/components/securityAuditModel.ts
- src/lib/authErrorMessage.ts
- src/lib/packageApi.ts
- src/lib/packageUpload.ts
@@ -38,8 +39,12 @@ paths:
- src/routes/skills/publish.tsx
- src/routes/upload.tsx
- src/routes/upload
- src/routes/$owner/$slug/security-audit.tsx
- src/routes/$owner/$slug/security
- src/routes/plugins/$name/security-audit.tsx
- src/routes/plugins/$name/security
- src/routes/plugins/$scope/$name/security-audit.tsx
- src/routes/plugins/$scope/$name/security
paths-ignore:
- "**/node_modules"
+2
View File
@@ -32,6 +32,7 @@ import type * as httpApi from "../httpApi.js";
import type * as httpApiV1 from "../httpApiV1.js";
import type * as httpApiV1_docsSessionV1 from "../httpApiV1/docsSessionV1.js";
import type * as httpApiV1_packagesV1 from "../httpApiV1/packagesV1.js";
import type * as httpApiV1_publishersV1 from "../httpApiV1/publishersV1.js";
import type * as httpApiV1_shared from "../httpApiV1/shared.js";
import type * as httpApiV1_skillsV1 from "../httpApiV1/skillsV1.js";
import type * as httpApiV1_soulsV1 from "../httpApiV1/soulsV1.js";
@@ -164,6 +165,7 @@ declare const fullApi: ApiFromModules<{
httpApiV1: typeof httpApiV1;
"httpApiV1/docsSessionV1": typeof httpApiV1_docsSessionV1;
"httpApiV1/packagesV1": typeof httpApiV1_packagesV1;
"httpApiV1/publishersV1": typeof httpApiV1_publishersV1;
"httpApiV1/shared": typeof httpApiV1_shared;
"httpApiV1/skillsV1": typeof httpApiV1_skillsV1;
"httpApiV1/soulsV1": typeof httpApiV1_soulsV1;
+3 -3
View File
@@ -41,7 +41,7 @@ The scanned fixtures should cover:
- artifact detail pages
- scan summary strips
- ClawScan report pages
- security audit pages
- publisher note display
- mobile and desktop security layout
- report/moderation state previews
@@ -52,9 +52,9 @@ After running `bun run dev` and `bunx convex dev`, use:
```text
http://localhost:3000/local/local-agentic-risk-demo
http://localhost:3000/local/local-agentic-risk-demo/security/clawscan
http://localhost:3000/local/local-agentic-risk-demo/security-audit
http://localhost:3000/plugins/local-scanned-runtime-plugin
http://localhost:3000/plugins/local-scanned-runtime-plugin/security/clawscan
http://localhost:3000/plugins/local-scanned-runtime-plugin/security-audit
```
The fixture pages should avoid appeal language. Publisher notes are untrusted publisher-provided context, not appeals, staff responses, or moderation decisions.
+2 -2
View File
@@ -29,8 +29,8 @@ Plugins:
- Canonical page: `/plugins/@scope/name`
- Encoded compatibility page: `/plugins/%40scope%2Fname`
- Security page: `/plugins/@scope/name/security/<scanner>`
- Encoded security compatibility page: `/plugins/%40scope%2Fname/security/<scanner>`
- Security audit page: `/plugins/@scope/name/security-audit`
- Encoded security compatibility page: `/plugins/%40scope%2Fname/security-audit`
Publisher profiles:
+21 -19
View File
@@ -362,30 +362,32 @@ describe("plugin detail route", () => {
render(<Component />);
expect(screen.getByRole("heading", { name: "Audits" })).toBeTruthy();
expect(screen.getAllByText("VirusTotal").length).toBeGreaterThan(0);
expect(screen.getAllByText("ClawScan").length).toBeGreaterThan(0);
expect(screen.getByRole("link", { name: /VirusTotal.*Pass/i }).getAttribute("href")).toBe(
"/plugins/demo-plugin/security/virustotal",
);
expect(screen.getByRole("link", { name: /Static analysis.*Pass/i }).getAttribute("href")).toBe(
"/plugins/demo-plugin/security/static-analysis",
expect(screen.getByText("Security audit")).toBeTruthy();
expect(screen.getByText("Pass")).toBeTruthy();
expect(screen.getByRole("link", { name: "View Security Audit" }).getAttribute("href")).toBe(
"/plugins/demo-plugin/security-audit",
);
expect(
screen.getByRole("button", {
name: "Security checks across static analysis, malware telemetry, and agentic risk",
}),
).toBeTruthy();
expect(screen.queryByText("Looks safe.")).toBeNull();
const securityHeading = screen.getByRole("heading", { name: "Audits" });
const installHeading = screen.getByRole("heading", { name: "Install" });
const sidebarMetadata = document.querySelector('dl[aria-label="Plugin metadata"]');
expect(sidebarMetadata).toBeTruthy();
const sidebarLabels = Array.from(
sidebarMetadata?.querySelectorAll(".sidebar-metadata-label") ?? [],
(label) => label.textContent?.trim(),
);
const capabilitiesTab = screen.getByRole("tab", { name: "Capabilities" });
expect(
securityHeading.compareDocumentPosition(capabilitiesTab) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
securityHeading.compareDocumentPosition(installHeading) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
const securityAuditLabelIndex = sidebarLabels.findIndex((label) =>
label?.startsWith("Security audit"),
);
expect(securityAuditLabelIndex).toBeGreaterThanOrEqual(0);
expect(securityAuditLabelIndex).toBe(sidebarLabels.indexOf("Owner") + 1);
fireEvent.click(capabilitiesTab);
expect(screen.getByText("Tags")).toBeTruthy();
expect(
installHeading.compareDocumentPosition(capabilitiesTab) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("does not render owner-only plugin scanner rerun state in the detail security summary", async () => {
@@ -35,7 +35,7 @@ describe("scoped plugin route redirects", () => {
expect(redirectMock).not.toHaveBeenCalled();
});
it("accepts scoped plugin security paths", async () => {
it("redirects old scoped plugin security scanner paths to the combined audit", async () => {
const route = await loadRoute("../routes/plugins/$scope/$name/security/$scanner");
expect(() =>
@@ -43,16 +43,19 @@ describe("scoped plugin route redirects", () => {
location: { pathname: "/plugins/@clawkit/clawkit-creative-studio/security/virustotal" },
params: { scope: "@clawkit", name: "clawkit-creative-studio", scanner: "virustotal" },
}),
).not.toThrow();
expect(redirectMock).not.toHaveBeenCalled();
).toThrow();
expect(redirectMock).toHaveBeenCalledWith({
href: "/plugins/@clawkit/clawkit-creative-studio/security-audit",
statusCode: 308,
});
});
it("accepts nested security paths through the scoped plugin parent", async () => {
it("accepts nested security audit paths through the scoped plugin parent", async () => {
const route = await loadRoute("../routes/plugins/$scope/$name");
expect(() =>
route.__config.beforeLoad({
location: { pathname: "/plugins/@clawkit/clawkit-creative-studio/security/virustotal" },
location: { pathname: "/plugins/@clawkit/clawkit-creative-studio/security-audit" },
params: { scope: "@clawkit", name: "clawkit-creative-studio" },
}),
).not.toThrow();
+24 -13
View File
@@ -351,7 +351,8 @@ describe("SkillDetailPage", () => {
);
await screen.findByRole("heading", { name: "Install" });
const securityHeading = screen.getByRole("heading", { name: "Audits" });
const sidebarMetadata = document.querySelector('dl[aria-label="Skill metadata"]');
expect(sidebarMetadata).toBeTruthy();
expect(screen.getAllByRole("heading", { name: "Install" }).length).toBeGreaterThan(0);
expect(screen.getAllByText("openclaw skills install weather").length).toBeGreaterThan(0);
@@ -360,10 +361,24 @@ describe("SkillDetailPage", () => {
expect(screen.getByRole("tab", { name: "CLI" }).getAttribute("aria-selected")).toBe("true");
expect(screen.getByRole("tab", { name: "Prompt" })).toBeTruthy();
expect(screen.queryByText(/After install, inspect the skill metadata/i)).toBeNull();
expect(securityHeading).toBeTruthy();
expect(screen.getByRole("link", { name: /VirusTotal.*Pending/i })).toBeTruthy();
expect(screen.getByRole("link", { name: /ClawScan.*Pending/i })).toBeTruthy();
expect(screen.getByRole("link", { name: /Static analysis.*Pending/i })).toBeTruthy();
expect(screen.getByText("Security audit")).toBeTruthy();
expect(screen.getByRole("link", { name: "View Security Audit" }).getAttribute("href")).toBe(
"/steipete/weather/security-audit",
);
const sidebarLabels = Array.from(
sidebarMetadata?.querySelectorAll(".sidebar-metadata-label") ?? [],
(label) => label.textContent?.trim(),
);
const securityAuditLabelIndex = sidebarLabels.findIndex((label) =>
label?.startsWith("Security audit"),
);
expect(securityAuditLabelIndex).toBe(sidebarLabels.indexOf("Owner") + 1);
expect(
screen.getByRole("button", {
name: "Security checks across static analysis, malware telemetry, and agentic risk",
}),
).toBeTruthy();
expect(screen.queryByText("No risk analysis has been recorded yet.")).toBeNull();
expect(screen.queryByText(/Like a lobster shell, security has layers/i)).toBeNull();
expect(screen.queryByRole("button", { name: "Rescan" })).toBeNull();
@@ -372,9 +387,6 @@ describe("SkillDetailPage", () => {
expect(
installHeading.compareDocumentPosition(filesTab) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
securityHeading.compareDocumentPosition(filesTab) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("applies staff-cleared moderation overrides to the public security summary", async () => {
@@ -471,11 +483,10 @@ describe("SkillDetailPage", () => {
/>,
);
await screen.findByRole("heading", { name: "Audits" });
expect(screen.getByText(/reviewed by staff and cleared/i)).toBeTruthy();
expect(screen.getByRole("link", { name: /VirusTotal.*Cleared/i })).toBeTruthy();
expect(screen.getByRole("link", { name: /ClawScan.*Cleared/i })).toBeTruthy();
expect(screen.getByRole("link", { name: /Static analysis.*Cleared/i })).toBeTruthy();
await screen.findByText("Security audit");
expect(screen.getByText("Cleared")).toBeTruthy();
expect(screen.getByRole("link", { name: "View Security Audit" })).toBeTruthy();
expect(screen.queryByText(/reviewed by staff and cleared/i)).toBeNull();
expect(screen.queryByRole("link", { name: /Suspicious/i })).toBeNull();
});
@@ -10,7 +10,6 @@ const useAuthStatusMock = vi.fn();
let paramsMock = {
owner: "local",
slug: "local-agentic-risk-demo",
scanner: "static-analysis",
};
let loaderDataMock: {
owner: string;
@@ -51,14 +50,14 @@ vi.mock("../lib/skillPage", () => ({
}));
async function loadRoute() {
return (await import("../routes/$owner/$slug/security/$scanner")).Route as unknown as {
return (await import("../routes/$owner/$slug/security-audit")).Route as unknown as {
__config: {
component?: ComponentType;
};
};
}
describe("skill security scanner route", () => {
describe("skill security audit route", () => {
beforeEach(() => {
useQueryMock.mockReset();
useQueryMock.mockReturnValue(undefined);
@@ -66,7 +65,6 @@ describe("skill security scanner route", () => {
paramsMock = {
owner: "local",
slug: "local-agentic-risk-demo",
scanner: "static-analysis",
};
loaderDataMock = {
owner: "local",
@@ -77,24 +75,15 @@ describe("skill security scanner route", () => {
};
});
it.each(["static-analysis", "clawscan", "virustotal"] as const)(
"renders a skeleton while %s security details are loading",
async (scanner) => {
paramsMock = {
owner: "local",
slug: "local-agentic-risk-demo",
scanner,
};
it("renders a skeleton while security audit details are loading", async () => {
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
render(<Component />);
expect(screen.queryByText("Loading security details...")).toBeNull();
const loadingRegion = screen.getByRole("status", { name: "Loading security details" });
expect(loadingRegion.getAttribute("aria-busy")).toBe("true");
expect(document.querySelector(".security-scanner-skeleton")).toBeTruthy();
},
);
expect(screen.queryByText("Loading security audit...")).toBeNull();
const loadingRegion = screen.getByRole("status", { name: "Loading security audit" });
expect(loadingRegion.getAttribute("aria-busy")).toBe("true");
expect(document.querySelector(".security-scanner-skeleton")).toBeTruthy();
});
});
+60 -47
View File
@@ -5,27 +5,50 @@ import { describe, expect, it } from "vitest";
import { DetailSecuritySummary } from "./DetailSecuritySummary";
describe("DetailSecuritySummary", () => {
it("shows scanner signals in the compact security audit row", () => {
render(<DetailSecuritySummary scannerBasePath="/steipete/weather/security" />);
it("shows the audit verdict and full audit link in the compact sidebar row", () => {
render(
<DetailSecuritySummary
auditHref="/steipete/weather/security-audit"
vtAnalysis={{ status: "clean", checkedAt: 1 }}
llmAnalysis={{
status: "clean",
summary: "ClawScan found the declared purpose aligned with the artifact.",
guidance: "Install from publishers you trust.",
checkedAt: 1,
}}
staticScan={{
status: "clean",
reasonCodes: [],
findings: [],
summary: "Clean.",
engineVersion: "v1",
checkedAt: 1,
}}
/>,
);
expect(screen.getByRole("heading", { name: "Audits" })).toBeTruthy();
expect(screen.getAllByText("Pending")).toHaveLength(4);
expect(screen.getByRole("link", { name: "VirusTotal: Pending" })).toBeTruthy();
expect(screen.getByRole("link", { name: "ClawScan: Pending" })).toBeTruthy();
expect(screen.getByRole("link", { name: "Static analysis: Pending" })).toBeTruthy();
expect(screen.queryByText("Pass")).toBeNull();
expect(screen.getByText("Pass")).toBeTruthy();
const auditLink = screen.getByRole("link", { name: "View Security Audit" });
expect(auditLink.getAttribute("href")).toBe("/steipete/weather/security-audit");
expect(auditLink.getAttribute("target")).toBeNull();
expect(auditLink.getAttribute("rel")).toBeNull();
expect(
screen
.getAllByRole("link")
.filter((link) => link.className.includes("security-audit-signal"))
.map((link) => link.getAttribute("aria-label")),
).toEqual(["ClawScan: Pending", "Static analysis: Pending", "VirusTotal: Pending"]);
screen.queryByText("ClawScan found the declared purpose aligned with the artifact."),
).toBeNull();
expect(screen.queryByText("Install from publishers you trust.")).toBeNull();
expect(screen.queryByRole("link", { name: /VirusTotal/i })).toBeNull();
expect(
screen.queryByText(
"Security checks across static analysis, malware telemetry, and agentic risk",
),
).toBeNull();
expect(document.querySelectorAll(".security-audit-meter span")).toHaveLength(4);
});
it("shows staff-cleared public scan summaries as cleared", () => {
render(
<DetailSecuritySummary
scannerBasePath="/suka233/kmind-markdown-to-mindmap/security"
auditHref="/suka233/kmind-markdown-to-mindmap/security-audit"
vtAnalysis={{ status: "suspicious", verdict: "suspicious", checkedAt: 1 }}
llmAnalysis={{ status: "suspicious", verdict: "suspicious", checkedAt: 1 }}
staticScan={{
@@ -46,26 +69,23 @@ describe("DetailSecuritySummary", () => {
checkedAt: 1,
}}
suppressScanResults
suppressedMessage="Security findings on these releases were reviewed by staff and cleared for public use."
/>,
);
expect(screen.getByText(/reviewed by staff and cleared/i)).toBeTruthy();
expect(screen.getByRole("link", { name: /VirusTotal.*Cleared/i })).toBeTruthy();
expect(screen.getByRole("link", { name: /ClawScan.*Cleared/i })).toBeTruthy();
expect(screen.getByRole("link", { name: /Static analysis.*Cleared/i })).toBeTruthy();
expect(screen.getByText("Cleared")).toBeTruthy();
expect(screen.queryByText("Warn")).toBeNull();
});
it("shows review and warn as separate audit states", () => {
it("rolls ClawScan review and warning states into the compact verdict", () => {
const { rerender } = render(
<DetailSecuritySummary
scannerBasePath="/steipete/weather/security"
auditHref="/steipete/weather/security-audit"
vtAnalysis={{ status: "clean", checkedAt: 1 }}
llmAnalysis={{
status: "suspicious",
verdict: "suspicious",
checkedAt: 1,
summary: "Review the requested permission boundary.",
agenticRiskFindings: [
{
categoryId: "ASI02",
@@ -95,17 +115,17 @@ describe("DetailSecuritySummary", () => {
/>,
);
expect(screen.getByRole("link", { name: "ClawScan: Review" })).toBeTruthy();
expect(screen.getAllByText("Review").length).toBeGreaterThan(0);
rerender(
<DetailSecuritySummary
scannerBasePath="/steipete/weather/security"
auditHref="/steipete/weather/security-audit"
vtAnalysis={{ status: "clean", checkedAt: 1 }}
llmAnalysis={{
status: "suspicious",
verdict: "suspicious",
checkedAt: 1,
summary: "High concern capability mismatch.",
agenticRiskFindings: [
{
categoryId: "ASI02",
@@ -135,7 +155,6 @@ describe("DetailSecuritySummary", () => {
/>,
);
expect(screen.getByRole("link", { name: "ClawScan: Warn" })).toBeTruthy();
expect(screen.getAllByText("Warn").length).toBeGreaterThan(0);
expect(screen.queryByText("Suspicious")).toBeNull();
});
@@ -143,9 +162,9 @@ describe("DetailSecuritySummary", () => {
it("renders clean scanner outcomes as pass in the user-facing audit UI", () => {
render(
<DetailSecuritySummary
scannerBasePath="/steipete/weather/security"
auditHref="/steipete/weather/security-audit"
vtAnalysis={{ status: "clean", checkedAt: 1 }}
llmAnalysis={{ status: "clean", checkedAt: 1 }}
llmAnalysis={{ status: "clean", summary: "No mismatches found.", checkedAt: 1 }}
staticScan={{
status: "clean",
reasonCodes: [],
@@ -157,26 +176,22 @@ describe("DetailSecuritySummary", () => {
/>,
);
expect(screen.getAllByText("Pass")).toHaveLength(4);
expect(screen.getByRole("link", { name: "VirusTotal: Pass" })).toBeTruthy();
expect(screen.getByRole("link", { name: "ClawScan: Pass" })).toBeTruthy();
expect(screen.getByRole("link", { name: "Static analysis: Pass" })).toBeTruthy();
expect(screen.getByText("Pass")).toBeTruthy();
expect(screen.queryByText("Benign")).toBeNull();
});
it("renders legacy VirusTotal AI fields from engine stats instead of source", () => {
it("keeps legacy non-engine VirusTotal fields neutral in the aggregate verdict", () => {
render(
<DetailSecuritySummary
scannerBasePath="/tokauthai/skillscan/security"
auditHref="/tokauthai/skillscan/security-audit"
vtAnalysis={{
status: "suspicious",
source: "legacy-ai",
scanner: "legacy-ai",
analysis: "Legacy AI advisory context.",
engineStats: { malicious: 0, suspicious: 0, harmless: 12, undetected: 54 },
checkedAt: 1,
}}
llmAnalysis={{ status: "clean", checkedAt: 1 }}
llmAnalysis={{ status: "clean", summary: "No ClawScan issues.", checkedAt: 1 }}
staticScan={{
status: "clean",
reasonCodes: [],
@@ -188,14 +203,15 @@ describe("DetailSecuritySummary", () => {
/>,
);
expect(screen.getByRole("link", { name: "VirusTotal: Pass" })).toBeTruthy();
expect(screen.getByText("Pass")).toBeTruthy();
expect(screen.queryByText("Advisory")).toBeNull();
expect(screen.queryByText("Warn")).toBeNull();
});
it("renders legacy VirusTotal AI fields without engine stats as neutral", () => {
render(
<DetailSecuritySummary
scannerBasePath="/tokauthai/skillscan/security"
auditHref="/tokauthai/skillscan/security-audit"
vtAnalysis={{
status: "suspicious",
source: "legacy-ai",
@@ -215,14 +231,14 @@ describe("DetailSecuritySummary", () => {
/>,
);
expect(screen.getByRole("link", { name: "VirusTotal: Pass" })).toBeTruthy();
expect(screen.getByText("Pass")).toBeTruthy();
expect(screen.queryByText("Warn")).toBeNull();
});
it("renders VirusTotal undetected-only fallback as pass", () => {
render(
<DetailSecuritySummary
scannerBasePath="/plugins/@opik/opik-openclaw/security"
auditHref="/plugins/@opik/opik-openclaw/security-audit"
vtAnalysis={{
status: "clean",
verdict: "undetected-only-fallback",
@@ -243,15 +259,14 @@ describe("DetailSecuritySummary", () => {
/>,
);
expect(screen.getByRole("link", { name: "VirusTotal: Pass" })).toBeTruthy();
expect(screen.getAllByText("Pass")).toHaveLength(4);
expect(screen.getByText("Pass")).toBeTruthy();
expect(screen.queryByText("undetected-only-fallback")).toBeNull();
});
it("shows static suspicious as review without rolling it up to suspicious", () => {
render(
<DetailSecuritySummary
scannerBasePath="/steipete/weather/security"
auditHref="/steipete/weather/security-audit"
vtAnalysis={{ status: "clean", checkedAt: 1 }}
llmAnalysis={{ status: "clean", checkedAt: 1 }}
staticScan={{
@@ -265,17 +280,16 @@ describe("DetailSecuritySummary", () => {
/>,
);
expect(screen.getByRole("link", { name: "Static analysis: Review" })).toBeTruthy();
expect(screen.getAllByText("Pass")).toHaveLength(3);
expect(screen.getByText("Review")).toBeTruthy();
expect(screen.queryByText("Warn")).toBeNull();
});
it("does not aggregate scanner operational errors as malicious verdicts", () => {
render(
<DetailSecuritySummary
scannerBasePath="/steipete/weather/security"
auditHref="/steipete/weather/security-audit"
vtAnalysis={{ status: "failed", checkedAt: 1 }}
llmAnalysis={{ status: "clean", checkedAt: 1 }}
llmAnalysis={{ status: "clean", summary: "No ClawScan issues.", checkedAt: 1 }}
staticScan={{
status: "clean",
reasonCodes: [],
@@ -287,8 +301,7 @@ describe("DetailSecuritySummary", () => {
/>,
);
expect(screen.getAllByText("Error")).toHaveLength(2);
expect(screen.getByRole("link", { name: "VirusTotal: Error" })).toBeTruthy();
expect(screen.getByText("Error")).toBeTruthy();
expect(screen.queryByText("Malicious")).toBeNull();
});
});
+61 -146
View File
@@ -1,176 +1,91 @@
import { Info } from "lucide-react";
import {
getClawScanDisplayStatus,
getScanStatusInfo,
getVirusTotalDisplayStatus,
type LlmAnalysis,
type StaticFinding,
type VtAnalysis,
} from "./SkillSecurityScanResults";
import { Badge, type BadgeProps } from "./ui/badge";
aggregateAuditVerdict,
SECURITY_AUDIT_SUBTEXT,
type StaticScanAnalysis,
} from "./securityAuditModel";
import { getScanStatusInfo, type LlmAnalysis, type VtAnalysis } from "./SkillSecurityScanResults";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
type DetailSecuritySummaryProps = {
scannerBasePath: string;
sha256hash?: string | null;
auditHref: string;
vtAnalysis?: VtAnalysis | null;
llmAnalysis?: LlmAnalysis | null;
staticScan?: {
status: string;
reasonCodes: string[];
findings: StaticFinding[];
summary: string;
engineVersion: string;
checkedAt: number;
} | null;
staticScan?: StaticScanAnalysis | null;
suppressScanResults?: boolean;
suppressedMessage?: string | null;
};
function statusFromStaticScan(staticScan: DetailSecuritySummaryProps["staticScan"]) {
const status = staticScan?.status?.trim().toLowerCase();
if (status === "malicious") return "malicious";
if (status === "clean" || status === "benign") return "benign";
if (status === "suspicious") return "review";
if (status) return status;
return "pending";
}
function severityLevelForStatus(status: string) {
const normalized = status.toLowerCase();
if (normalized === "malicious") return 4;
if (normalized === "warn" || normalized === "warning" || normalized === "suspicious") return 3;
if (normalized === "review") return 2;
if (normalized === "clean" || normalized === "benign" || normalized === "cleared") return 1;
return 0;
}
function aggregateAuditVerdict(statuses: string[]) {
const normalized = statuses.map((status) => status.toLowerCase());
if (normalized.some((status) => status === "malicious")) {
return "malicious";
}
if (
normalized.some(
(status) => status === "warn" || status === "warning" || status === "suspicious",
)
) {
return "warn";
}
if (normalized.some((status) => status === "error" || status === "failed")) return "error";
if (
normalized.some(
(status) => status === "pending" || status === "loading" || status === "not_found",
)
) {
return "pending";
}
return "benign";
}
function auditVerdictBadgeVariant(status: string): BadgeProps["variant"] {
function auditVerdictMeterLevel(status: string) {
switch (status.toLowerCase()) {
case "malicious":
return "destructive";
return 4;
case "warn":
case "warning":
case "suspicious":
return "warning";
case "pending":
case "error":
case "failed":
return "pending";
return 3;
case "review":
return 2;
case "benign":
case "clean":
case "cleared":
return 1;
default:
return "success";
return 0;
}
}
function ScannerSignal({
href,
label,
description,
status,
tone,
}: {
href: string;
label: string;
description: string;
status: string;
tone?: "review";
}) {
const info = getScanStatusInfo(status);
const level = severityLevelForStatus(status);
return (
<a
href={href}
className="security-audit-signal !no-underline hover:!no-underline"
aria-label={`${label}: ${info.label}`}
>
<div className="security-audit-signal-head">
<span className="security-audit-signal-label">{label}</span>
<span className="security-audit-signal-status">{info.label}</span>
</div>
<div className="security-audit-meter" data-level={level} data-tone={tone} aria-hidden="true">
<span />
<span />
<span />
<span />
</div>
<p>{description}</p>
</a>
);
}
export function DetailSecuritySummary({
scannerBasePath,
auditHref,
vtAnalysis,
llmAnalysis,
staticScan,
suppressScanResults = false,
suppressedMessage,
}: DetailSecuritySummaryProps) {
const vtStatus = suppressScanResults ? "cleared" : getVirusTotalDisplayStatus(vtAnalysis);
const llmStatus = suppressScanResults ? "cleared" : getClawScanDisplayStatus(llmAnalysis);
const staticStatus = suppressScanResults ? "cleared" : statusFromStaticScan(staticScan);
const auditVerdict = aggregateAuditVerdict([vtStatus, llmStatus, staticStatus]);
const auditVerdict = aggregateAuditVerdict({
vtAnalysis,
llmAnalysis,
staticScan,
suppressScanResults,
});
const auditVerdictInfo = getScanStatusInfo(auditVerdict);
const meterLevel = auditVerdictMeterLevel(auditVerdict);
return (
<section className="security-audit-section" aria-labelledby="security-audit-heading">
<div className="security-audit-title-row">
<h3 id="security-audit-heading" className="skill-install-panel-title security-audit-title">
Audits
</h3>
<Badge
variant={auditVerdictBadgeVariant(auditVerdict)}
className="security-audit-verdict-badge min-h-0 rounded-[4px] px-2.5 py-0.5 text-[0.78rem] leading-[1.3]"
>
<a href={auditHref} className="security-audit-sidebar-value" aria-label="View Security Audit">
<div className="security-audit-sidebar-value-row">
<span className="security-audit-sidebar-verdict" data-status={auditVerdict}>
{auditVerdictInfo.label}
</Badge>
</div>
<div className="security-audit-row">
{suppressScanResults && suppressedMessage ? (
<p className="security-audit-suppressed">{suppressedMessage}</p>
) : null}
<div className="security-audit-signals">
<ScannerSignal
href={`${scannerBasePath}/clawscan`}
label="ClawScan"
description="Agentic behavior and permission review."
status={llmStatus}
tone="review"
/>
<ScannerSignal
href={`${scannerBasePath}/static-analysis`}
label="Static analysis"
description="Pattern checks against bundled files."
status={staticStatus}
/>
<ScannerSignal
href={`${scannerBasePath}/virustotal`}
label="VirusTotal"
description="Multi-engine malware detections and file reputation."
status={vtStatus}
/>
</span>
<div className="security-audit-meter" data-level={meterLevel} aria-hidden="true">
<span />
<span />
<span />
<span />
</div>
</div>
</section>
</a>
);
}
export function DetailSecuritySummaryLabel() {
return (
<span className="security-audit-sidebar-label">
<span>Security audit</span>
<TooltipProvider delayDuration={400}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="security-audit-sidebar-info"
aria-label={SECURITY_AUDIT_SUBTEXT}
>
<Info size={13} aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="security-report-title-tooltip">
{SECURITY_AUDIT_SUBTEXT}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
);
}
@@ -1,14 +1,9 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { PublisherClawScanNote } from "./PublisherClawScanNote";
import { TooltipProvider } from "./ui/tooltip";
function renderNote(note: string) {
return render(
<TooltipProvider>
<PublisherClawScanNote note={note} />
</TooltipProvider>,
);
return render(<PublisherClawScanNote note={note} />);
}
describe("PublisherClawScanNote", () => {
@@ -34,9 +29,10 @@ describe("PublisherClawScanNote", () => {
);
});
it("renders the note help affordance", () => {
it("renders the note title without the removed help affordance", () => {
renderNote("Publisher context.");
expect(screen.getByRole("button", { name: "About publisher ClawScan notes" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "Publisher note" })).toBeTruthy();
expect(screen.queryByRole("button", { name: /About publisher/i })).toBeNull();
});
});
+1 -17
View File
@@ -1,6 +1,4 @@
import { Info } from "lucide-react";
import { useId, useState } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
type PublisherClawScanNoteProps = {
note?: string | null;
@@ -17,7 +15,7 @@ export function PublisherClawScanNote({ note, compact = false }: PublisherClawSc
return (
<section
className={`publisher-clawscan-note${compact ? " publisher-clawscan-note-compact" : ""}`}
className={`publisher-clawscan-note${compact ? " publisher-clawscan-note-compact security-report-panel-compact" : ""}`}
aria-labelledby={headingId}
>
<div className="security-report-panel-header publisher-clawscan-note-header">
@@ -25,20 +23,6 @@ export function PublisherClawScanNote({ note, compact = false }: PublisherClawSc
<h2 id={headingId} className="skill-install-panel-title">
Publisher note
</h2>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="publisher-clawscan-note-info"
aria-label="About publisher ClawScan notes"
>
<Info aria-hidden="true" size={16} strokeWidth={2} />
</button>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="publisher-clawscan-note-tooltip">
Additional notes the publisher has provided to ClawScan for context when reviewing
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="publisher-clawscan-note-body">
+672
View File
@@ -0,0 +1,672 @@
import { Clock, ExternalLink, Info, X } from "lucide-react";
import { useEffect, useState } from "react";
import type { Id } from "../../convex/_generated/dataModel";
import { getRuntimeEnv } from "../lib/runtimeEnv";
import { PublisherClawScanNote } from "./PublisherClawScanNote";
import {
aggregateAuditVerdict,
AUDIT_SCANNER_LABELS,
SECURITY_AUDIT_SUBTEXT,
getAuditScannerOrder,
getAuditScannerStatus,
getLatestAuditCheckedAt,
getSecurityAuditOverviewCopy,
type AuditScannerKind,
type StaticScanAnalysis,
} from "./securityAuditModel";
import { SidebarMetadata } from "./SidebarMetadata";
import {
ClawScanRiskReview,
FindingSeverityBadge,
getClawScanRiskLevel,
hasClawScanRiskReview,
RiskLevelBadge,
ScanResultBadge,
type LlmAnalysis,
type VtAnalysis,
} from "./SkillSecurityScanResults";
import { Alert, AlertDescription } from "./ui/alert";
import { Skeleton } from "./ui/skeleton";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
type OwnerRef = {
_id?: string;
handle?: string | null;
};
type EntityRef = {
kind: "skill" | "plugin";
title: string;
name: string;
version?: string | null;
owner?: OwnerRef | null;
ownerUserId?: Id<"users"> | null;
ownerPublisherId?: Id<"publishers"> | null;
detailPath: string;
};
type SecurityAuditPageProps = {
entity: EntityRef;
sha256hash?: string | null;
vtAnalysis?: VtAnalysis | null;
llmAnalysis?: LlmAnalysis | null;
staticScan?: StaticScanAnalysis | null;
source?: Record<string, unknown> | null;
clawScanNote?: string | null;
canManageArtifact?: boolean;
settingsHref?: string | null;
};
const EMPTY_STATIC_FINDINGS: StaticScanAnalysis["findings"] = [];
const RISK_ANALYSIS_SCOPE_COPY =
"Risk analysis is mapped to the OWASP Agentic Skills Top 10 using artifact evidence from this release.";
function formatTime(value?: number | null) {
if (!value) return "Not checked yet";
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
function extractDetailPathParts(detailPath: string) {
return detailPath.split("/").filter(Boolean).map(decodeURIComponent);
}
function getOwnerLabel(entity: EntityRef) {
if (entity.owner?.handle) return entity.owner.handle;
const parts = extractDetailPathParts(entity.detailPath);
if (entity.kind === "skill") return parts[0] ?? "unknown";
return entity.owner?._id ?? "plugins";
}
function SecurityAuditHero({ props }: { props: SecurityAuditPageProps }) {
const ownerLabel = getOwnerLabel(props.entity);
const listingLabel = props.entity.kind === "skill" ? "skills" : "plugins";
const ownerHref =
props.entity.kind === "skill" ? `/user/${encodeURIComponent(ownerLabel)}` : "/plugins";
return (
<header className="security-scan-hero">
<nav className="skill-hero-breadcrumbs" aria-label="Breadcrumb">
<a href={`/${listingLabel}`}>{listingLabel}</a>
<span aria-hidden="true">/</span>
<a href={ownerHref}>{ownerLabel}</a>
<span aria-hidden="true">/</span>
<a href={props.entity.detailPath}>{props.entity.name}</a>
<span aria-hidden="true">/</span>
<span>Security Audit</span>
</nav>
<div className="security-scan-hero-heading">
<h1 className="skill-page-title">{props.entity.title}</h1>
<p className="security-scan-hero-subtext">{SECURITY_AUDIT_SUBTEXT}</p>
</div>
</header>
);
}
function getVirusTotalEngineStats(analysis?: VtAnalysis | null) {
return analysis?.engineStats ?? analysis?.metadata?.stats ?? null;
}
function hasEngineVirusTotalSource(analysis?: VtAnalysis | null) {
const source = analysis?.source?.trim().toLowerCase();
const scanner = analysis?.scanner?.trim().toLowerCase();
return Boolean(source?.startsWith("engines") || scanner?.startsWith("engines"));
}
function hasNonEngineVirusTotalSource(analysis?: VtAnalysis | null) {
if (!analysis) return false;
const source = analysis.source?.trim().toLowerCase();
const scanner = analysis.scanner?.trim().toLowerCase();
return Boolean(
(source && !source.startsWith("engines")) || (scanner && !scanner.startsWith("engines")),
);
}
function getArtifactKindLabel(entity: EntityRef) {
return entity.kind === "plugin" ? "plugin" : "skill";
}
function getVirusTotalNoFindingsCopy(entity: EntityRef) {
return `No VirusTotal findings for this ${getArtifactKindLabel(entity)} version.`;
}
function getVirusTotalPendingCopy(entity: EntityRef) {
return `VirusTotal findings are pending for this ${getArtifactKindLabel(entity)} version.`;
}
function getVirusTotalEngineOverview(analysis: VtAnalysis | null | undefined, entity: EntityRef) {
const stats = getVirusTotalEngineStats(analysis);
if (stats) {
const malicious = stats.malicious ?? 0;
const suspicious = stats.suspicious ?? 0;
if (malicious > 0 || suspicious > 0) {
return `VirusTotal vendor engines reported ${malicious} malicious and ${suspicious} suspicious detection(s) for this artifact. ClawHub treats this as telemetry for risk analysis, not as a standalone blocking verdict.`;
}
return getVirusTotalNoFindingsCopy(entity);
}
if (hasNonEngineVirusTotalSource(analysis)) {
return getVirusTotalNoFindingsCopy(entity);
}
const status = analysis?.status?.trim().toLowerCase();
if (status && !["loading", "not_found", "pending"].includes(status)) {
return `VirusTotal engine telemetry is currently ${status} for this artifact.`;
}
return null;
}
function getVirusTotalAnalysisText(analysis?: VtAnalysis | null) {
if (!analysis?.analysis || !hasEngineVirusTotalSource(analysis)) return null;
return analysis.analysis;
}
function getVirusTotalOverviewCopy(analysis: VtAnalysis | null | undefined, entity: EntityRef) {
return (
getVirusTotalAnalysisText(analysis) ??
getVirusTotalEngineOverview(analysis, entity) ??
getVirusTotalPendingCopy(entity)
);
}
function isReviewStatus(status: string) {
const normalized = status.trim().toLowerCase();
return normalized === "review" || normalized === "warn" || normalized === "suspicious";
}
function PublisherNotePrompt({
storageKey,
settingsHref,
}: {
storageKey: string;
settingsHref: string;
}) {
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
setDismissed(window.localStorage.getItem(storageKey) === "1");
}, [storageKey]);
if (dismissed) return null;
function dismiss() {
setDismissed(true);
if (typeof window !== "undefined") window.localStorage.setItem(storageKey, "1");
}
return (
<Alert variant="info" className="publisher-note-prompt" role="status">
<Info size={18} aria-hidden="true" />
<AlertDescription>
<a href={settingsHref}>Add a publisher note</a> to give this audit context on these
findings.
</AlertDescription>
<button type="button" onClick={dismiss} aria-label="Dismiss publisher note prompt">
<X size={16} aria-hidden="true" />
</button>
</Alert>
);
}
function SecurityAuditOverview(props: SecurityAuditPageProps) {
const overviewCopy = getSecurityAuditOverviewCopy({ llmAnalysis: props.llmAnalysis });
return (
<section
className="security-report-panel security-report-panel-compact"
aria-labelledby="overview-heading"
>
<div className="security-report-panel-header">
<h2 id="overview-heading" className="skill-install-panel-title">
Overview
</h2>
</div>
<div className="security-report-overview-body">
{overviewCopy.map((copy, index) => (
<p key={`security-audit-overview-${index}`}>{copy}</p>
))}
</div>
</section>
);
}
function ClawScanSection(props: SecurityAuditPageProps) {
const riskAnalysis =
props.llmAnalysis && hasClawScanRiskReview(props.llmAnalysis) ? props.llmAnalysis : null;
return (
<div className="security-report-panel-body security-report-panel-body-findings">
{riskAnalysis ? (
<ClawScanRiskReview analysis={riskAnalysis} showTitle={false} />
) : (
<p className="security-audit-empty-detail">
No visible risk-analysis findings were reported for this release.
</p>
)}
</div>
);
}
function PublisherNoteSection(props: SecurityAuditPageProps) {
const status = getAuditScannerStatus("clawscan", props);
const riskAnalysis =
props.llmAnalysis && hasClawScanRiskReview(props.llmAnalysis) ? props.llmAnalysis : null;
const showPublisherNotePrompt =
props.canManageArtifact &&
props.settingsHref &&
!props.clawScanNote?.trim() &&
isReviewStatus(status) &&
Boolean(riskAnalysis);
const publisherNotePromptHref = showPublisherNotePrompt ? props.settingsHref : null;
const publisherNotePromptStorageKey = `clawhub.publisher-note-prompt.${props.entity.kind}.${props.entity.name}.${props.entity.version ?? "latest"}`;
return (
<>
<PublisherClawScanNote note={props.clawScanNote} compact />
{publisherNotePromptHref ? (
<PublisherNotePrompt
storageKey={publisherNotePromptStorageKey}
settingsHref={publisherNotePromptHref}
/>
) : null}
</>
);
}
function VirusTotalSection(props: SecurityAuditPageProps) {
const stats = getVirusTotalEngineStats(props.vtAnalysis);
const vtUrl = props.sha256hash ? `https://www.virustotal.com/gui/file/${props.sha256hash}` : null;
return (
<div className="security-report-panel-body">
<div className="security-report-overview-body">
<p>{getVirusTotalOverviewCopy(props.vtAnalysis, props.entity)}</p>
</div>
{stats ? (
<dl className="security-audit-stat-grid" aria-label="VirusTotal engine stats">
<div>
<dt>Malicious</dt>
<dd>{stats.malicious ?? 0}</dd>
</div>
<div>
<dt>Suspicious</dt>
<dd>{stats.suspicious ?? 0}</dd>
</div>
<div>
<dt>Harmless</dt>
<dd>{stats.harmless ?? 0}</dd>
</div>
<div>
<dt>Undetected</dt>
<dd>{stats.undetected ?? 0}</dd>
</div>
</dl>
) : null}
{vtUrl ? (
<a
href={vtUrl}
target="_blank"
rel="noopener noreferrer"
className="security-audit-external-link"
>
View on VirusTotal
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
) : null}
</div>
);
}
function formatStaticFindingTitle(code: string) {
const withoutPrefix = code.replace(/^(?:suspicious|malicious|review)\./, "");
const words = withoutPrefix
.split(/[._-]+/)
.filter(Boolean)
.map((part) => part.toLowerCase());
if (!words.length) return code;
return [words[0].charAt(0).toUpperCase() + words[0].slice(1), ...words.slice(1)].join(" ");
}
function getStaticFindingKey(finding: StaticScanAnalysis["findings"][number]) {
return `${finding.file}:${finding.line}`;
}
function resolveAbsoluteBaseUrl(...candidates: Array<string | undefined>) {
for (const candidate of candidates) {
const value = candidate?.trim();
if (!value) continue;
try {
return new URL(value).toString();
} catch {
continue;
}
}
return null;
}
function buildArtifactFileUrl(entity: EntityRef, path: string) {
const base =
entity.kind === "skill"
? `/api/v1/skills/${encodeURIComponent(entity.name)}/file`
: `/api/v1/packages/${encodeURIComponent(entity.name)}/file`;
const params = new URLSearchParams({ path });
if (entity.version) params.set("version", entity.version);
const relativePath = `${base}?${params.toString()}`;
const convexClientBaseUrl = resolveAbsoluteBaseUrl(
getRuntimeEnv("VITE_CONVEX_SITE_URL"),
getRuntimeEnv("VITE_CONVEX_URL"),
);
if (
typeof window !== "undefined" &&
convexClientBaseUrl &&
["localhost", "127.0.0.1", "0.0.0.0"].includes(window.location.hostname)
) {
return new URL(relativePath, convexClientBaseUrl).toString();
}
return relativePath;
}
function extractLineFromFile(content: string, line: number) {
if (!Number.isFinite(line) || line < 1) return null;
const value = content.split(/\r?\n/)[line - 1]?.trimEnd();
return value?.trim() ? value : null;
}
function useStaticFindingSnippets(entity: EntityRef, findings: StaticScanAnalysis["findings"]) {
const [snippets, setSnippets] = useState<Record<string, string>>({});
useEffect(() => {
const controller = new AbortController();
if (!findings.length) {
setSnippets({});
return () => controller.abort();
}
const uniqueFindings = Array.from(
new Map(findings.map((finding) => [getStaticFindingKey(finding), finding])).values(),
);
async function loadSnippets() {
const entries = await Promise.all(
uniqueFindings.map(async (finding) => {
try {
const response = await fetch(buildArtifactFileUrl(entity, finding.file), {
signal: controller.signal,
});
if (!response.ok) return null;
const content = await response.text();
const snippet = extractLineFromFile(content, finding.line);
return snippet ? ([getStaticFindingKey(finding), snippet] as const) : null;
} catch {
return null;
}
}),
);
if (!controller.signal.aborted) {
setSnippets(Object.fromEntries(entries.filter((entry) => entry !== null)));
}
}
void loadSnippets();
return () => controller.abort();
}, [entity.kind, entity.name, entity.version, findings]);
return snippets;
}
function StaticAnalysisSection(props: SecurityAuditPageProps) {
const status = props.staticScan?.status?.trim().toLowerCase() ?? null;
const findings = props.staticScan?.findings ?? EMPTY_STATIC_FINDINGS;
const fetchedSnippets = useStaticFindingSnippets(props.entity, findings);
const emptyCopy =
status === "clean" || status === "benign"
? "No static analysis findings were reported for this release."
: status && !["loading", "not_found", "pending"].includes(status)
? `Static analysis reported ${status} with no visible findings.`
: "Static analysis findings are pending for this release.";
return (
<div className="security-report-panel-body security-report-panel-body-findings">
{findings.length ? (
<div className="static-analysis-findings">
{findings.map((finding, index) => (
<StaticAnalysisFinding
key={`${finding.code}-${finding.file}-${finding.line}-${index}`}
finding={finding}
snippet={fetchedSnippets[getStaticFindingKey(finding)] ?? finding.evidence}
/>
))}
</div>
) : (
<p className="security-audit-empty-detail">{emptyCopy}</p>
)}
</div>
);
}
function StaticAnalysisFinding({
finding,
snippet,
}: {
finding: StaticScanAnalysis["findings"][number];
snippet?: string | null;
}) {
const trimmedSnippet = snippet?.trim();
return (
<article className="static-analysis-finding">
<div className="static-analysis-finding-header">
<h3 className="agentic-risk-finding-title">{formatStaticFindingTitle(finding.code)}</h3>
<div className="agentic-risk-finding-badges">
<FindingSeverityBadge severity={finding.severity} />
</div>
</div>
<dl className="static-analysis-finding-details">
<div>
<dt>Finding</dt>
<dd>{finding.message}</dd>
</div>
{trimmedSnippet ? (
<div>
<dt>Skill content</dt>
<dd>
<pre className="agentic-risk-evidence-snippet">{trimmedSnippet}</pre>
</dd>
</div>
) : null}
</dl>
</article>
);
}
function RiskAnalysisInfoLink() {
return (
<TooltipProvider delayDuration={400}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="security-report-title-info-link"
aria-label={RISK_ANALYSIS_SCOPE_COPY}
>
<Info size={15} aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="security-report-title-tooltip">
{RISK_ANALYSIS_SCOPE_COPY}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function SecurityAuditScannerSection({
kind,
props,
}: {
kind: AuditScannerKind;
props: SecurityAuditPageProps;
}) {
const label = AUDIT_SCANNER_LABELS[kind];
return (
<section
className="security-report-panel security-report-panel-compact"
aria-labelledby={`${kind}-heading`}
>
<div className="security-report-panel-header">
<div className="security-report-panel-title-row">
<h2 id={`${kind}-heading`} className="skill-install-panel-title">
{label}
</h2>
{kind === "clawscan" ? <RiskAnalysisInfoLink /> : null}
</div>
</div>
{kind === "clawscan" ? <ClawScanSection {...props} /> : null}
{kind === "virustotal" ? <VirusTotalSection {...props} /> : null}
{kind === "static-analysis" ? <StaticAnalysisSection {...props} /> : null}
</section>
);
}
function SecurityAuditSidebar(props: SecurityAuditPageProps) {
const latestCheckedAt = getLatestAuditCheckedAt(props);
const clawScanRiskLevel = getClawScanRiskLevel(props.llmAnalysis);
const verdict = aggregateAuditVerdict(props);
return (
<SidebarMetadata
ariaLabel="Security audit metadata"
density="compact"
blocks={[
{
label: "Outcome",
value: <ScanResultBadge status={verdict} />,
},
{
label: "Risk",
value: clawScanRiskLevel ? <RiskLevelBadge level={clawScanRiskLevel} /> : "Not reported",
},
{
label: "Latest audit",
value: (
<span className="sidebar-metadata-inline">
<Clock className="h-3.5 w-3.5" aria-hidden="true" />
{formatTime(latestCheckedAt)}
</span>
),
},
{ label: "Version", value: props.entity.version ?? "Latest" },
]}
/>
);
}
export function SecurityAuditPage(props: SecurityAuditPageProps) {
const orderedScanners = getAuditScannerOrder();
return (
<main className="section detail-page-section security-report-section">
<div className="security-report-shell">
<SecurityAuditHero props={props} />
<div className="security-report-layout">
<div className="security-report-main">
<SecurityAuditOverview {...props} />
<PublisherNoteSection {...props} />
{orderedScanners.map((kind) => (
<SecurityAuditScannerSection key={kind} kind={kind} props={props} />
))}
</div>
<aside className="security-report-sidebar" aria-label="Security audit metadata">
<h2 className="sr-only">Security Audit Metadata</h2>
<SecurityAuditSidebar {...props} />
</aside>
</div>
</div>
</main>
);
}
export function SecurityAuditPageSkeleton() {
return (
<main className="section detail-page-section security-report-section">
<div
className="security-report-shell security-scanner-skeleton"
role="status"
aria-label="Loading security audit"
aria-busy="true"
>
<header className="security-scan-hero">
<div className="skill-hero-breadcrumbs">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-3" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-3" />
<Skeleton className="h-4 w-40 max-w-[42vw]" />
<Skeleton className="h-4 w-3" />
<Skeleton className="h-4 w-28" />
</div>
<div className="security-scan-hero-heading">
<Skeleton className="h-12 w-full max-w-[520px]" />
<div className="security-scan-hero-subtext">
<Skeleton className="h-8 w-24 rounded-[var(--r-pill)]" />
<Skeleton className="h-5 w-full max-w-[340px]" />
</div>
</div>
</header>
<div className="security-report-layout">
<div className="security-report-main">
{Array.from({ length: 3 }).map((_, index) => (
<section
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholder count
key={index}
className="security-report-panel"
>
<div className="security-report-panel-header">
<Skeleton className="h-6 w-32" />
</div>
<div className="security-report-overview-body">
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-11/12" />
<Skeleton className="h-5 w-3/4" />
</div>
</section>
))}
</div>
<aside className="security-report-sidebar" aria-label="Security audit metadata">
<div className="sidebar-metadata sidebar-metadata-compact">
<div className="sidebar-metadata-row">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-5 w-40" />
</div>
<div className="sidebar-metadata-grid">
<div className="sidebar-metadata-row">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-5 w-10" />
</div>
<div className="sidebar-metadata-row">
<Skeleton className="h-3 w-14" />
<Skeleton className="h-5 w-16" />
</div>
</div>
<div className="sidebar-metadata-row">
<Skeleton className="h-3 w-24" />
<div className="security-report-badge-list">
<Skeleton className="h-6 w-16 rounded-[var(--r-pill)]" />
<Skeleton className="h-6 w-20 rounded-[var(--r-pill)]" />
</div>
</div>
</div>
</aside>
</div>
</div>
</main>
);
}
-616
View File
@@ -1,616 +0,0 @@
import { Clock, ExternalLink, Info, X } from "lucide-react";
import { useEffect, useState } from "react";
import type { Id } from "../../convex/_generated/dataModel";
import { PublisherClawScanNote } from "./PublisherClawScanNote";
import { SidebarMetadata } from "./SidebarMetadata";
import {
ClawScanRiskReview,
getClawScanDisplayStatus,
getClawScanRiskLevel,
getVisibleClawScanFindingCount,
getVirusTotalDisplayStatus,
hasClawScanRiskReview,
RiskLevelBadge,
ScanResultBadge,
type LlmAnalysis,
type StaticFinding,
type VtAnalysis,
} from "./SkillSecurityScanResults";
import { Alert, AlertDescription } from "./ui/alert";
import { Badge } from "./ui/badge";
import { Skeleton } from "./ui/skeleton";
export type ScannerSlug = "virustotal" | "clawscan" | "static-analysis";
type OwnerRef = {
_id?: string;
handle?: string | null;
};
type EntityRef = {
kind: "skill" | "plugin";
title: string;
name: string;
version?: string | null;
owner?: OwnerRef | null;
ownerUserId?: Id<"users"> | null;
ownerPublisherId?: Id<"publishers"> | null;
detailPath: string;
};
type SecurityScannerPageProps = {
scanner: ScannerSlug;
entity: EntityRef;
sha256hash?: string | null;
vtAnalysis?: VtAnalysis | null;
llmAnalysis?: LlmAnalysis | null;
staticScan?: {
status: string;
reasonCodes: string[];
findings: StaticFinding[];
summary: string;
engineVersion: string;
checkedAt: number;
} | null;
source?: Record<string, unknown> | null;
clawScanNote?: string | null;
canManageArtifact?: boolean;
settingsHref?: string | null;
};
const SCANNER_LABELS: Record<ScannerSlug, string> = {
virustotal: "VirusTotal",
clawscan: "ClawScan",
"static-analysis": "Static analysis",
};
function formatTime(value?: number | null) {
if (!value) return "Not checked yet";
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
function formatDate(value?: number | null) {
if (!value) return null;
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
}).format(new Date(value));
}
function formatValue(value: unknown): string | null {
if (value === undefined || value === null || value === "") return null;
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (Array.isArray(value))
return value.length ? value.map(formatValue).filter(Boolean).join(", ") : null;
return JSON.stringify(value);
}
function getScannerStatus(props: SecurityScannerPageProps) {
if (props.scanner === "virustotal") return getVirusTotalDisplayStatus(props.vtAnalysis);
if (props.scanner === "clawscan") return getClawScanDisplayStatus(props.llmAnalysis);
if (props.staticScan?.status?.toLowerCase() === "malicious") return "malicious";
return props.staticScan ? "advisory" : "pending";
}
function getCheckedAt(props: SecurityScannerPageProps) {
if (props.scanner === "virustotal") return props.vtAnalysis?.checkedAt ?? null;
if (props.scanner === "clawscan") return props.llmAnalysis?.checkedAt ?? null;
return props.staticScan?.checkedAt ?? null;
}
function scannerCrumbLabel(label: string) {
return label.toLowerCase();
}
function extractDetailPathParts(detailPath: string) {
return detailPath.split("/").filter(Boolean).map(decodeURIComponent);
}
function getOwnerLabel(entity: EntityRef) {
if (entity.owner?.handle) return entity.owner.handle;
const parts = extractDetailPathParts(entity.detailPath);
if (entity.kind === "skill") return parts[0] ?? "unknown";
return entity.owner?._id ?? "plugins";
}
function getSecurityHeroSubtext(label: string, checkedAt: number | null) {
const checkedDate = formatDate(checkedAt);
if (!checkedDate) return `${label} audit pending.`;
return `Audited by ${label} on ${checkedDate}.`;
}
function SecurityScannerHero({ label, props }: { label: string; props: SecurityScannerPageProps }) {
const status = getScannerStatus(props);
const checkedAt = getCheckedAt(props);
const ownerLabel = getOwnerLabel(props.entity);
const listingLabel = props.entity.kind === "skill" ? "skills" : "plugins";
const ownerHref =
props.entity.kind === "skill" ? `/user/${encodeURIComponent(ownerLabel)}` : "/plugins";
return (
<header className="security-scan-hero">
<nav className="skill-hero-breadcrumbs" aria-label="Breadcrumb">
<a href={`/${listingLabel}`}>{listingLabel}</a>
<span aria-hidden="true">/</span>
<a href={ownerHref}>{ownerLabel}</a>
<span aria-hidden="true">/</span>
<a href={props.entity.detailPath}>{props.entity.name}</a>
<span aria-hidden="true">/</span>
<span>{scannerCrumbLabel(label)}</span>
</nav>
<div className="security-scan-hero-heading">
<h1 className="skill-page-title">{props.entity.title}</h1>
<p className="security-scan-hero-subtext">
<ScanResultBadge
status={status}
tone={props.scanner === "clawscan" ? "review" : undefined}
/>
<span>{getSecurityHeroSubtext(label, checkedAt)}</span>
</p>
</div>
</header>
);
}
function getVisibleFindingCount(props: SecurityScannerPageProps) {
if (props.scanner === "static-analysis") return props.staticScan?.findings?.length ?? 0;
if (props.scanner === "clawscan") return getVisibleClawScanFindingCount(props.llmAnalysis);
return 0;
}
function getVirusTotalEngineStats(analysis?: VtAnalysis | null) {
return analysis?.engineStats ?? analysis?.metadata?.stats ?? null;
}
function hasEngineVirusTotalSource(analysis?: VtAnalysis | null) {
const source = analysis?.source?.trim().toLowerCase();
const scanner = analysis?.scanner?.trim().toLowerCase();
return Boolean(source?.startsWith("engines") || scanner?.startsWith("engines"));
}
function hasNonEngineVirusTotalSource(analysis?: VtAnalysis | null) {
if (!analysis) return false;
const source = analysis.source?.trim().toLowerCase();
const scanner = analysis.scanner?.trim().toLowerCase();
return Boolean(
(source && !source.startsWith("engines")) || (scanner && !scanner.startsWith("engines")),
);
}
function getVirusTotalEngineOverview(analysis?: VtAnalysis | null) {
const stats = getVirusTotalEngineStats(analysis);
if (stats) {
const malicious = stats.malicious ?? 0;
const suspicious = stats.suspicious ?? 0;
if (malicious > 0 || suspicious > 0) {
return `VirusTotal vendor engines reported ${malicious} malicious and ${suspicious} suspicious detection(s) for this artifact. ClawHub treats this as telemetry for ClawScan, not as a standalone blocking verdict.`;
}
return "VirusTotal vendor engines reported no malicious or suspicious detections for this artifact.";
}
if (hasNonEngineVirusTotalSource(analysis)) {
return "No VirusTotal vendor-engine telemetry has been recorded for this artifact.";
}
const status = analysis?.status?.trim().toLowerCase();
if (status && !["loading", "not_found", "pending"].includes(status)) {
return `VirusTotal engine telemetry is currently ${status} for this artifact.`;
}
return null;
}
function getVirusTotalAnalysisText(analysis?: VtAnalysis | null) {
if (!analysis?.analysis || !hasEngineVirusTotalSource(analysis)) return null;
return analysis.analysis;
}
function getVirusTotalSourceLabel(analysis?: VtAnalysis | null) {
if (getVirusTotalEngineStats(analysis) || hasEngineVirusTotalSource(analysis)) {
return analysis?.source ?? "Engine telemetry";
}
return "File reputation";
}
function getOverviewCopy(props: SecurityScannerPageProps) {
if (props.scanner === "virustotal") {
return [
getVirusTotalAnalysisText(props.vtAnalysis) ??
getVirusTotalEngineOverview(props.vtAnalysis) ??
"No VirusTotal analysis has been recorded yet. File reputation checks will appear here once the artifact hash has been scanned.",
];
}
if (props.scanner === "static-analysis") {
return [
props.staticScan?.summary ??
"No static analysis result has been recorded yet. Pattern checks will appear here once the artifact has been analyzed.",
];
}
return [
props.llmAnalysis?.summary ?? "No ClawScan analysis has been recorded yet.",
props.llmAnalysis?.guidance ?? null,
];
}
function isReviewStatus(status: string) {
const normalized = status.trim().toLowerCase();
return normalized === "review" || normalized === "warn" || normalized === "suspicious";
}
function PublisherNotePrompt({
storageKey,
settingsHref,
}: {
storageKey: string;
settingsHref: string;
}) {
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
setDismissed(window.localStorage.getItem(storageKey) === "1");
}, [storageKey]);
if (dismissed) return null;
function dismiss() {
setDismissed(true);
if (typeof window !== "undefined") window.localStorage.setItem(storageKey, "1");
}
return (
<Alert variant="info" className="publisher-note-prompt" role="status">
<Info size={18} aria-hidden="true" />
<AlertDescription>
<a href={settingsHref}>Add a publisher note</a> to give ClawScan context on these findings.
</AlertDescription>
<button type="button" onClick={dismiss} aria-label="Dismiss publisher note prompt">
<X size={16} aria-hidden="true" />
</button>
</Alert>
);
}
function SecurityScannerReport(props: SecurityScannerPageProps) {
const label = SCANNER_LABELS[props.scanner];
const status = getScannerStatus(props);
const checkedAt = getCheckedAt(props);
const vtUrl =
props.scanner === "virustotal" && props.sha256hash
? `https://www.virustotal.com/gui/file/${props.sha256hash}`
: null;
const sourceRepo = formatValue(
props.source?.repository ?? props.source?.repo ?? props.source?.url,
);
const sourceCommit = formatValue(props.source?.commit ?? props.source?.sha);
const riskAnalysis =
props.scanner === "clawscan" && props.llmAnalysis && hasClawScanRiskReview(props.llmAnalysis)
? props.llmAnalysis
: null;
const riskLevel = props.scanner === "clawscan" ? getClawScanRiskLevel(props.llmAnalysis) : null;
const visibleFindingCount = getVisibleFindingCount(props);
const overviewCopy = getOverviewCopy(props).filter(Boolean);
const showPublisherNotePrompt =
props.scanner === "clawscan" &&
props.canManageArtifact &&
props.settingsHref &&
!props.clawScanNote?.trim() &&
isReviewStatus(status) &&
Boolean(riskAnalysis);
const publisherNotePromptHref = showPublisherNotePrompt ? props.settingsHref : null;
const publisherNotePromptStorageKey = `clawhub.publisher-note-prompt.${props.entity.kind}.${props.entity.name}.${props.entity.version ?? "latest"}`;
return (
<main className="section detail-page-section security-report-section">
<div className="security-report-shell">
<SecurityScannerHero label={label} props={props} />
<div className="security-report-layout">
<div className="security-report-main">
{overviewCopy.length > 0 ? (
<section className="security-report-panel" aria-labelledby="overview-heading">
<div className="security-report-panel-header">
<h2 id="overview-heading" className="skill-install-panel-title">
Overview
</h2>
</div>
<div className="security-report-overview-body">
{overviewCopy.map((copy, index) => (
<p key={`${props.scanner}-overview-${index}`}>{copy}</p>
))}
</div>
</section>
) : null}
{props.scanner === "clawscan" ? (
<PublisherClawScanNote note={props.clawScanNote} />
) : null}
{riskAnalysis ? (
<section className="security-report-panel" aria-labelledby="agentic-findings-heading">
<div className="security-report-panel-header">
<h2 id="agentic-findings-heading" className="skill-install-panel-title">
Findings ({visibleFindingCount})
</h2>
</div>
<div className="security-report-panel-body">
{publisherNotePromptHref ? (
<PublisherNotePrompt
storageKey={publisherNotePromptStorageKey}
settingsHref={publisherNotePromptHref}
/>
) : null}
<ClawScanRiskReview analysis={riskAnalysis} showTitle={false} />
</div>
</section>
) : null}
{props.scanner === "static-analysis" && props.staticScan?.findings?.length ? (
<section className="security-report-panel" aria-labelledby="static-findings-heading">
<div className="security-report-panel-header">
<h2 id="static-findings-heading" className="skill-install-panel-title">
Findings ({visibleFindingCount})
</h2>
</div>
<div className="security-report-panel-body">
<div className="static-analysis-findings">
{props.staticScan.findings.map((finding, index) => (
<article
key={`${finding.code}-${finding.file}-${finding.line}-${index}`}
className="static-analysis-finding"
>
<div className="static-analysis-finding-header">
<Badge variant="compact">{finding.severity}</Badge>
<h3>{finding.code}</h3>
</div>
<dl className="static-analysis-finding-details">
<div>
<dt>Location</dt>
<dd className="font-mono">
{finding.file}:{finding.line}
</dd>
</div>
<div>
<dt>Finding</dt>
<dd>{finding.message}</dd>
</div>
{finding.evidence ? (
<div>
<dt>Evidence</dt>
<dd>
<pre>{finding.evidence}</pre>
</dd>
</div>
) : null}
</dl>
</article>
))}
</div>
</div>
</section>
) : null}
</div>
<aside className="security-report-sidebar" aria-label="Scan metadata">
<h2 className="sr-only">Scan Metadata</h2>
<SidebarMetadata
ariaLabel="Scan metadata"
density="compact"
blocks={[
...(props.scanner === "clawscan"
? [
{
label: "Risk level",
value: riskLevel ? <RiskLevelBadge level={riskLevel} /> : null,
},
]
: []),
{
label: "Analyzed",
value: (
<span className="sidebar-metadata-inline">
<Clock className="h-3.5 w-3.5" aria-hidden="true" />
{formatTime(checkedAt)}
</span>
),
},
{
grid: [
{ label: "Findings", value: visibleFindingCount },
{ label: "Version", value: props.entity.version ?? "Latest" },
],
},
...(props.scanner === "static-analysis"
? [
{
label: "Reason codes",
value: props.staticScan?.reasonCodes?.length ? (
<div className="security-report-badge-list">
{props.staticScan.reasonCodes.map((code) => (
<Badge key={code} variant="compact">
{code}
</Badge>
))}
</div>
) : (
"None"
),
},
{
label: "Engine",
value: props.staticScan?.engineVersion ?? "Not reported",
},
]
: []),
...(props.scanner === "virustotal"
? [
{
label: "Hash",
value: props.sha256hash ? (
<span className="break-all font-mono text-xs">{props.sha256hash}</span>
) : (
"Not recorded"
),
},
{
label: "Source",
value: getVirusTotalSourceLabel(props.vtAnalysis),
},
{
label: "External report",
value: vtUrl ? (
<a
href={vtUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 break-all text-[color:var(--accent)] hover:underline"
>
View on VirusTotal
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
) : (
"Unavailable until an artifact hash is recorded."
),
},
]
: []),
...(props.scanner === "clawscan" && props.entity.kind === "plugin"
? [
{
label: "Hash",
value: props.sha256hash ? (
<span className="break-all font-mono text-xs">{props.sha256hash}</span>
) : (
"Not recorded"
),
},
]
: []),
{ label: "Source repository", value: sourceRepo },
{
label: "Source commit",
value: sourceCommit ? (
<span className="font-mono text-xs">{sourceCommit}</span>
) : null,
},
]}
/>
</aside>
</div>
</div>
</main>
);
}
export function SecurityScannerPage(props: SecurityScannerPageProps) {
return <SecurityScannerReport {...props} />;
}
export function SecurityScannerPageSkeleton() {
return (
<main className="section detail-page-section security-report-section">
<div
className="security-report-shell security-scanner-skeleton"
role="status"
aria-label="Loading security details"
aria-busy="true"
>
<header className="security-scan-hero">
<div className="skill-hero-breadcrumbs">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-3" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-3" />
<Skeleton className="h-4 w-40 max-w-[42vw]" />
<Skeleton className="h-4 w-3" />
<Skeleton className="h-4 w-28" />
</div>
<div className="security-scan-hero-heading">
<Skeleton className="h-12 w-full max-w-[520px]" />
<div className="security-scan-hero-subtext">
<Skeleton className="h-8 w-24 rounded-[var(--r-pill)]" />
<Skeleton className="h-5 w-full max-w-[340px]" />
</div>
</div>
</header>
<div className="security-report-layout">
<div className="security-report-main">
<section className="security-report-panel">
<div className="security-report-panel-header">
<Skeleton className="h-6 w-28" />
</div>
<div className="security-report-overview-body">
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-11/12" />
<Skeleton className="h-5 w-3/4" />
</div>
</section>
<section className="security-report-panel">
<div className="security-report-panel-header">
<Skeleton className="h-6 w-32" />
</div>
<div className="security-report-panel-body">
<div className="static-analysis-findings">
{Array.from({ length: 2 }).map((_, index) => (
<article
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholder count
key={index}
className="static-analysis-finding"
>
<div className="static-analysis-finding-header">
<Skeleton className="h-6 w-16 rounded-[var(--r-pill)]" />
<Skeleton className="h-5 w-48 max-w-full" />
</div>
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-16 w-full rounded-[var(--r-sm)]" />
</div>
</article>
))}
</div>
</div>
</section>
</div>
<aside className="security-report-sidebar" aria-label="Scan metadata">
<div className="sidebar-metadata sidebar-metadata-compact">
<div className="sidebar-metadata-row">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-5 w-40" />
</div>
<div className="sidebar-metadata-grid">
<div className="sidebar-metadata-row">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-5 w-10" />
</div>
<div className="sidebar-metadata-row">
<Skeleton className="h-3 w-14" />
<Skeleton className="h-5 w-16" />
</div>
</div>
<div className="sidebar-metadata-row">
<Skeleton className="h-3 w-24" />
<div className="security-report-badge-list">
<Skeleton className="h-6 w-16 rounded-[var(--r-pill)]" />
<Skeleton className="h-6 w-20 rounded-[var(--r-pill)]" />
</div>
</div>
<div className="sidebar-metadata-row">
<Skeleton className="h-3 w-14" />
<Skeleton className="h-5 w-28" />
</div>
</div>
</aside>
</div>
</div>
</main>
);
}
+12 -4
View File
@@ -2,7 +2,8 @@ import type { ReactNode } from "react";
import { cn } from "../lib/utils";
type SidebarMetadataItem = {
label: string;
label: ReactNode;
key?: string;
value: ReactNode;
large?: boolean;
};
@@ -17,6 +18,10 @@ function isGridBlock(block: SidebarMetadataBlock): block is { grid: SidebarMetad
return "grid" in block;
}
function getSidebarMetadataItemKey(item: SidebarMetadataItem, fallback: string) {
return item.key ?? (typeof item.label === "string" ? item.label : fallback);
}
function SidebarMetadataRow({ item }: { item: SidebarMetadataItem }) {
if (item.value === null || item.value === undefined || item.value === "") return null;
return (
@@ -50,12 +55,15 @@ export function SidebarMetadata({
{blocks.map((block, index) =>
isGridBlock(block) ? (
<div className="sidebar-metadata-grid" key={`grid-${index}`}>
{block.grid.map((item) => (
<SidebarMetadataRow key={item.label} item={item} />
{block.grid.map((item, itemIndex) => (
<SidebarMetadataRow
key={getSidebarMetadataItemKey(item, `grid-${index}-${itemIndex}`)}
item={item}
/>
))}
</div>
) : (
<SidebarMetadataRow key={block.label} item={block} />
<SidebarMetadataRow key={getSidebarMetadataItemKey(block, `row-${index}`)} item={block} />
),
)}
</dl>
+11 -18
View File
@@ -594,29 +594,21 @@ export function SkillDetailPage({
const securitySummary = latestVersion ? (
<DetailSecuritySummary
scannerBasePath={`/${encodeURIComponent(
ownerParam ?? ownerHandle ?? "unknown",
)}/${encodeURIComponent(skill.slug)}/security`}
sha256hash={latestVersion.sha256hash ?? null}
auditHref={`/${encodeURIComponent(ownerParam ?? ownerHandle ?? "unknown")}/${encodeURIComponent(
skill.slug,
)}/security-audit`}
vtAnalysis={latestVersion.vtAnalysis ?? null}
llmAnalysis={latestVersion.llmAnalysis ?? null}
staticScan={latestVersion.staticScan ?? null}
suppressScanResults={suppressVersionScanResults}
suppressedMessage={scanResultsSuppressedMessage}
/>
) : null;
const priorityContent =
staffModerationNote || securitySummary ? (
<>
{staffModerationNote ? (
<Alert variant="warn" className="skill-visibility-alert" role="status">
<TriangleAlert size={18} aria-hidden="true" />
<AlertDescription>{staffModerationNote}</AlertDescription>
</Alert>
) : null}
{securitySummary}
</>
) : null;
const staffVisibilityAlert = staffModerationNote ? (
<Alert variant="warn" className="skill-visibility-alert" role="status">
<TriangleAlert size={18} aria-hidden="true" />
<AlertDescription>{staffModerationNote}</AlertDescription>
</Alert>
) : null;
const settingsPanel =
canAccessSettings && skill ? (
<SkillOwnershipPanel
@@ -697,7 +689,8 @@ export function SkillDetailPage({
cliHelp={cliHelp}
clawdis={clawdis}
category={relatedCategory}
priorityContent={priorityContent}
priorityContent={staffVisibilityAlert}
securityAuditSummary={securitySummary}
newVersionHref={newVersionHref}
settingsHref={settingsHref}
>
+19 -7
View File
@@ -11,6 +11,7 @@ import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { getRuntimeEnv } from "../lib/runtimeEnv";
import { timeAgo } from "../lib/timeAgo";
import { DetailHero } from "./DetailPageShell";
import { DetailSecuritySummaryLabel } from "./DetailSecuritySummary";
import { SidebarMetadata } from "./SidebarMetadata";
import { buildSkillHref } from "./skillDetailUtils";
import { SkillCommandLineCard } from "./SkillInstallSurface";
@@ -90,6 +91,8 @@ type SkillHeaderProps = {
clawdis: ClawdisSkillMetadata | undefined;
category?: SkillCategory | null;
priorityContent?: ReactNode;
postInstallContent?: ReactNode;
securityAuditSummary?: ReactNode;
newVersionHref?: string | null;
settingsHref?: string | null;
children?: ReactNode;
@@ -122,6 +125,8 @@ export function SkillHeader({
clawdis,
category,
priorityContent,
postInstallContent,
securityAuditSummary,
newVersionHref,
settingsHref,
children,
@@ -186,6 +191,7 @@ export function SkillHeader({
ownerHandle={ownerHandle}
formattedStats={formattedStats}
latestVersion={latestVersion}
securityAuditSummary={securityAuditSummary}
/>
{hasSidebarActions ? (
<div className="skill-sidebar-actions">
@@ -360,6 +366,8 @@ export function SkillHeader({
clawdis={clawdis}
/>
{postInstallContent}
{children}
{hasPluginBundle ? (
@@ -432,15 +440,15 @@ function SkillSidebarStats({
ownerHandle,
formattedStats,
latestVersion,
securityAuditSummary,
}: {
skill: Doc<"skills"> | PublicSkill;
owner: PublicPublisher | null;
ownerHandle: string | null;
formattedStats: ReturnType<typeof formatSkillStatsTriplet>;
latestVersion: SkillHeaderLatestVersion;
securityAuditSummary?: ReactNode;
}) {
const versionCount = skill.stats.versions ?? 0;
return (
<SidebarMetadata
ariaLabel="Skill metadata"
@@ -461,19 +469,23 @@ function SkillSidebarStats({
/>
),
},
securityAuditSummary
? {
key: "security-audit",
label: <DetailSecuritySummaryLabel />,
value: securityAuditSummary,
}
: { label: "", value: null },
{ label: "Last updated", value: timeAgo(skill.updatedAt) },
{
grid: [
{
label: "Current version",
value: latestVersion?.version ? `v${latestVersion.version}` : "None",
},
{ label: "Versions", value: versionCount },
{ label: "License", value: PLATFORM_SKILL_LICENSE },
],
},
{
grid: [{ label: "License", value: PLATFORM_SKILL_LICENSE }],
},
{ label: "Last updated", value: timeAgo(skill.updatedAt) },
]}
/>
);
+8 -18
View File
@@ -16,7 +16,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
const PROMPT_OPTIONS: Array<{
description: string;
@@ -219,23 +218,14 @@ export function SkillCommandLineCard({
<div className="skill-install-command-wrap">
<div className="skill-install-command-shell">
<TooltipProvider delayDuration={400}>
<Tooltip>
<TooltipTrigger asChild>
<pre
className={`skill-install-command${
activeInstallTab === "prompt" ? " skill-install-prompt-compact" : ""
}`}
tabIndex={0}
>
<code translate="no">{activeInstallText}</code>
</pre>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="skill-install-command-tooltip">
<code translate="no">{activeInstallText}</code>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<pre
className={`skill-install-command${
activeInstallTab === "prompt" ? " skill-install-prompt-compact" : ""
}`}
tabIndex={0}
>
<code translate="no">{activeInstallText}</code>
</pre>
<InstallCopyButton
text={activeInstallText}
ariaLabel={
+149 -70
View File
@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { SecurityScannerPage } from "./SecurityScannerPage";
import { SecurityAuditPage } from "./SecurityAuditPage";
import { SecurityScanResults, type LlmAnalysis } from "./SkillSecurityScanResults";
const clawScanAnalysis: LlmAnalysis = {
@@ -254,7 +254,7 @@ describe("SecurityScanResults static guidance", () => {
it("shows low risk for clean ClawScan scans", () => {
render(<SecurityScanResults llmAnalysis={{ status: "clean", checkedAt: Date.now() }} />);
expect(screen.getByText("Pass")).toBeTruthy();
expect(screen.getAllByText("Pass").length).toBeGreaterThan(0);
expect(screen.getByText("Low")).toBeTruthy();
});
@@ -368,10 +368,9 @@ describe("SecurityScanResults static guidance", () => {
expect(screen.queryByText("Findings")).toBeNull();
});
it("shows ClawScan buckets on the dedicated ClawScan report page", () => {
render(
<SecurityScannerPage
scanner="clawscan"
it("shows ClawScan buckets on the dedicated security audit page", () => {
const { container } = render(
<SecurityAuditPage
entity={{
kind: "skill",
title: "Todo Guard",
@@ -380,19 +379,31 @@ describe("SecurityScanResults static guidance", () => {
detailPath: "/local/todo-guard",
}}
llmAnalysis={clawScanAnalysis}
clawScanNote="Publisher says the Todoist token is required for task sync."
/>,
);
expect(screen.getByRole("heading", { name: "Todo Guard" })).toBeTruthy();
expect(screen.getAllByText("Warn").length).toBeGreaterThan(0);
expect(screen.getByText("Risk level")).toBeTruthy();
expect(screen.getByText("Risk")).toBeTruthy();
expect(screen.queryByText("ClawScan risk")).toBeNull();
expect(screen.getByText("High")).toBeTruthy();
expect(screen.queryByText("Verdict")).toBeNull();
expect(screen.getByText(/Audited by ClawScan/i)).toBeTruthy();
expect(
screen.getByText(
"Security checks across static analysis, malware telemetry, and agentic risk",
),
).toBeTruthy();
expect(container.querySelector(".security-scan-hero-subtext")?.textContent).not.toContain(
"Warn",
);
expect(screen.queryByText(/Current verdict/i)).toBeNull();
expect(screen.getByRole("heading", { name: "Overview" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "Publisher note" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "Risk analysis" })).toBeTruthy();
expect(screen.queryByRole("heading", { name: "ClawScan" })).toBeNull();
expect(screen.getByText(/Collects workspace secrets/i)).toBeTruthy();
expect(screen.getByRole("heading", { name: "Findings (2)" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "Scan Metadata" })).toBeTruthy();
expect(screen.queryByRole("heading", { name: "Findings (2)" })).toBeNull();
expect(screen.getByRole("heading", { name: "Security Audit Metadata" })).toBeTruthy();
expect(screen.queryByText("Legacy dimensions")).toBeNull();
expect(screen.queryByText("Scanner")).toBeNull();
expect(screen.queryByText("Review scope")).toBeNull();
@@ -400,17 +411,36 @@ describe("SecurityScanResults static guidance", () => {
expect(
screen.getByText("ASI03: Identity and Privilege Abuse").closest("a")?.getAttribute("href"),
).toBe("https://owasp.org/www-project-agentic-skills-top-10/ast03");
expect(
screen
.getByRole("button", {
name: "Risk analysis is mapped to the OWASP Agentic Skills Top 10 using artifact evidence from this release.",
})
.tagName.toLowerCase(),
).toBe("button");
expect(screen.getByText("ASI03: Identity and Privilege Abuse")).toBeTruthy();
expect(screen.queryByText("metadata")).toBeNull();
expect(screen.getAllByText("Skill content").length).toBeGreaterThan(0);
expect(screen.getByText("requires.env: TODOIST_API_TOKEN")).toBeTruthy();
expect(screen.queryByText("Confidence")).toBeNull();
expect(container.querySelector('nav[aria-label="Breadcrumb"]')?.textContent).toContain(
"Security Audit",
);
expect(
Array.from(
container.querySelectorAll(".security-report-sidebar .sidebar-metadata-label"),
).map((node) => node.textContent?.trim()),
).toEqual(["Outcome", "Risk", "Latest audit", "Version"]);
expect(
Array.from(container.querySelectorAll(".security-report-main > section h2")).map((node) =>
node.textContent?.trim(),
),
).toEqual(["Overview", "Publisher note", "Static analysis", "VirusTotal", "Risk analysis"]);
});
it("adds in-page permalinks to dedicated ClawScan findings", () => {
render(
<SecurityScannerPage
scanner="clawscan"
<SecurityAuditPage
entity={{
kind: "skill",
title: "Todo Guard",
@@ -436,8 +466,7 @@ describe("SecurityScanResults static guidance", () => {
it("prompts publishers to add a note on review ClawScan reports without one", () => {
render(
<SecurityScannerPage
scanner="clawscan"
<SecurityAuditPage
entity={{
kind: "skill",
title: "Todo Guard",
@@ -453,12 +482,11 @@ describe("SecurityScanResults static guidance", () => {
const link = screen.getByRole("link", { name: "Add a publisher note" });
expect(link.getAttribute("href")).toBe("/local/todo-guard/settings");
expect(screen.getByText(/to give ClawScan context on these findings/i)).toBeTruthy();
expect(screen.getByText(/to give this audit context on these findings/i)).toBeTruthy();
});
it("hides the publisher note prompt for non-publishers and after dismissal", () => {
const props = {
scanner: "clawscan" as const,
entity: {
kind: "skill" as const,
title: "Todo Guard",
@@ -470,21 +498,20 @@ describe("SecurityScanResults static guidance", () => {
settingsHref: "/local/todo-guard/settings",
};
const { rerender } = render(<SecurityScannerPage {...props} />);
const { rerender } = render(<SecurityAuditPage {...props} />);
expect(screen.queryByRole("link", { name: "Add a publisher note" })).toBeNull();
rerender(<SecurityScannerPage {...props} canManageArtifact />);
rerender(<SecurityAuditPage {...props} canManageArtifact />);
fireEvent.click(screen.getByRole("button", { name: "Dismiss publisher note prompt" }));
expect(screen.queryByRole("link", { name: "Add a publisher note" })).toBeNull();
rerender(<SecurityScannerPage {...props} canManageArtifact />);
rerender(<SecurityAuditPage {...props} canManageArtifact />);
expect(screen.queryByRole("link", { name: "Add a publisher note" })).toBeNull();
});
it("shows package hash metadata for plugin ClawScan reports", () => {
it("keeps plugin audit metadata focused while preserving hash links", () => {
render(
<SecurityScannerPage
scanner="clawscan"
<SecurityAuditPage
entity={{
kind: "plugin",
title: "Plugin Guard",
@@ -498,15 +525,21 @@ describe("SecurityScanResults static guidance", () => {
);
expect(screen.getByRole("heading", { name: "Plugin Guard" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "Scan Metadata" })).toBeTruthy();
expect(screen.getByText("Hash")).toBeTruthy();
expect(screen.getByText("seeded-plugin-hash")).toBeTruthy();
expect(screen.getByRole("heading", { name: "Security Audit Metadata" })).toBeTruthy();
expect(screen.getByText("Outcome")).toBeTruthy();
expect(screen.getByText("Risk")).toBeTruthy();
expect(screen.getByText("Latest audit")).toBeTruthy();
expect(screen.getByText("Version")).toBeTruthy();
expect(screen.queryByText("Hash")).toBeNull();
expect(screen.queryByText("seeded-plugin-hash")).toBeNull();
expect(screen.getByRole("link", { name: /View on VirusTotal/i }).getAttribute("href")).toBe(
"https://www.virustotal.com/gui/file/seeded-plugin-hash",
);
});
it("shows VirusTotal reports in the shared scanner report shell", () => {
render(
<SecurityScannerPage
scanner="virustotal"
const { container } = render(
<SecurityAuditPage
entity={{
kind: "skill",
title: "Hash Guard",
@@ -522,26 +555,35 @@ describe("SecurityScanResults static guidance", () => {
engineStats: { malicious: 0, suspicious: 0, harmless: 4, undetected: 58 },
checkedAt: Date.now(),
}}
llmAnalysis={clawScanAnalysis}
/>,
);
expect(screen.getByRole("heading", { name: "Hash Guard" })).toBeTruthy();
expect(screen.getByText(/Audited by VirusTotal/i)).toBeTruthy();
expect(
screen.getByText(
"Security checks across static analysis, malware telemetry, and agentic risk",
),
).toBeTruthy();
expect(screen.getByRole("heading", { name: "Overview" })).toBeTruthy();
expect(screen.getByText(/VirusTotal vendor engines reported no malicious/i)).toBeTruthy();
expect(screen.getByRole("heading", { name: "Scan Metadata" })).toBeTruthy();
expect(screen.getByText("abc123")).toBeTruthy();
expect(screen.getByText(/No VirusTotal findings for this skill version/i)).toBeTruthy();
expect(screen.getByRole("heading", { name: "Security Audit Metadata" })).toBeTruthy();
expect(screen.getByRole("link", { name: /View on VirusTotal/i }).getAttribute("href")).toBe(
"https://www.virustotal.com/gui/file/abc123",
);
expect(screen.queryByRole("heading", { name: /Findings/i })).toBeNull();
expect(screen.queryByText("ASI03: Identity and Privilege Abuse")).toBeNull();
expect(screen.queryByText("Scanner verdict")).toBeNull();
expect(screen.queryByText("Artifact")).toBeNull();
expect(
Array.from(container.querySelectorAll(".security-report-main > section h2")).map((node) =>
node.textContent?.trim(),
),
).toEqual(["Overview", "Static analysis", "VirusTotal", "Risk analysis"]);
});
it("summarizes completed engine-only VirusTotal scans", () => {
render(
<SecurityScannerPage
scanner="virustotal"
<SecurityAuditPage
entity={{
kind: "skill",
title: "Hash Guard",
@@ -556,19 +598,17 @@ describe("SecurityScanResults static guidance", () => {
engineStats: { malicious: 0, suspicious: 0, harmless: 2, undetected: 60 },
checkedAt: Date.now(),
}}
llmAnalysis={clawScanAnalysis}
/>,
);
expect(screen.getByRole("heading", { name: "Overview" })).toBeTruthy();
expect(screen.getByText(/VirusTotal vendor engines reported no malicious/i)).toBeTruthy();
expect(screen.getByText(/No VirusTotal findings for this skill version/i)).toBeTruthy();
expect(screen.queryByText(/No VirusTotal analysis has been recorded/i)).toBeNull();
});
it("renders VirusTotal undetected-only fallback as pass", () => {
render(
<SecurityScannerPage
scanner="virustotal"
<SecurityAuditPage
entity={{
kind: "plugin",
title: "Opik",
@@ -585,11 +625,19 @@ describe("SecurityScanResults static guidance", () => {
source: "engines-undetected-fallback",
checkedAt: Date.now(),
}}
llmAnalysis={clawScanAnalysis}
llmAnalysis={{ status: "clean", summary: "No ClawScan issues.", checkedAt: 1 }}
staticScan={{
status: "clean",
reasonCodes: [],
findings: [],
summary: "Clean.",
engineVersion: "v1",
checkedAt: 1,
}}
/>,
);
expect(screen.getByText(/Audited by VirusTotal/i)).toBeTruthy();
expect(screen.getByRole("heading", { name: "VirusTotal" })).toBeTruthy();
expect(screen.getByText("Pass")).toBeTruthy();
expect(
screen.getByText(/VirusTotal reported no malicious or suspicious engine hits/i),
@@ -599,8 +647,7 @@ describe("SecurityScanResults static guidance", () => {
it("treats legacy non-engine VirusTotal text as neutral and hidden", () => {
render(
<SecurityScannerPage
scanner="virustotal"
<SecurityAuditPage
entity={{
kind: "skill",
title: "SkillScan",
@@ -615,23 +662,25 @@ describe("SecurityScanResults static guidance", () => {
source: "legacy-ai",
checkedAt: Date.now(),
}}
llmAnalysis={clawScanAnalysis}
/>,
);
expect(screen.getByText(/Audited by VirusTotal/i)).toBeTruthy();
expect(screen.getByText("Pass")).toBeTruthy();
expect(
screen.getByText(
"Security checks across static analysis, malware telemetry, and agentic risk",
),
).toBeTruthy();
expect(screen.queryByText("Pass")).toBeNull();
expect(screen.getByRole("heading", { name: "Overview" })).toBeTruthy();
expect(screen.queryByText(/multi-engine malware detections/i)).toBeNull();
expect(screen.queryByRole("heading", { name: /Findings/ })).toBeNull();
expect(screen.queryByText(/raw AI context/i)).toBeNull();
expect(screen.getByText(/No VirusTotal vendor-engine telemetry/i)).toBeTruthy();
expect(screen.getByText(/No VirusTotal findings for this skill version/i)).toBeTruthy();
});
it("shows static analysis reports in the shared scanner report shell", () => {
render(
<SecurityScannerPage
scanner="static-analysis"
const { container } = render(
<SecurityAuditPage
entity={{
kind: "skill",
title: "Pattern Guard",
@@ -660,22 +709,34 @@ describe("SecurityScanResults static guidance", () => {
);
expect(screen.getByRole("heading", { name: "Pattern Guard" })).toBeTruthy();
expect(screen.getByText(/Audited by Static analysis/i)).toBeTruthy();
expect(
screen.getByText(
"Security checks across static analysis, malware telemetry, and agentic risk",
),
).toBeTruthy();
expect(screen.getByRole("heading", { name: "Overview" })).toBeTruthy();
expect(screen.getByText("Pattern checks found a network request.")).toBeTruthy();
expect(screen.getByRole("heading", { name: "Findings (1)" })).toBeTruthy();
expect(screen.getByText("suspicious.network_access")).toBeTruthy();
expect(screen.getByText("SKILL.md:12")).toBeTruthy();
expect(screen.queryByText("Pattern checks found a network request.")).toBeNull();
expect(screen.queryByRole("heading", { name: "Findings (1)" })).toBeNull();
expect(screen.getByRole("heading", { name: "Network access" })).toBeTruthy();
expect(screen.queryByText("suspicious.network_access")).toBeNull();
expect(screen.getByText("Network access found in skill instructions.")).toBeTruthy();
expect(screen.queryByText("Location")).toBeNull();
expect(screen.queryByText("SKILL.md:12")).toBeNull();
expect(screen.getByText("Skill content")).toBeTruthy();
expect(screen.getByText("curl https://example.test")).toBeTruthy();
expect(screen.getByRole("heading", { name: "Scan Metadata" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "Security Audit Metadata" })).toBeTruthy();
expect(screen.queryByText("Scanner verdict")).toBeNull();
expect(screen.queryByText("Artifact")).toBeNull();
expect(
Array.from(container.querySelectorAll(".security-report-main > section h2")).map((node) =>
node.textContent?.trim(),
),
).toEqual(["Overview", "Static analysis", "VirusTotal", "Risk analysis"]);
});
it("shows plugins with legacy ClawScan analysis in the new ClawScan report shell", () => {
render(
<SecurityScannerPage
scanner="clawscan"
<SecurityAuditPage
entity={{
kind: "plugin",
title: "Plugin Guard",
@@ -688,11 +749,15 @@ describe("SecurityScanResults static guidance", () => {
);
expect(screen.getByRole("heading", { name: "Plugin Guard" })).toBeTruthy();
expect(screen.getByText(/Audited by ClawScan/i)).toBeTruthy();
expect(
screen.getByText(
"Security checks across static analysis, malware telemetry, and agentic risk",
),
).toBeTruthy();
expect(screen.getByText("Legacy plugin analysis summary.")).toBeTruthy();
expect(screen.getByText("Legacy plugin guidance.")).toBeTruthy();
expect(screen.getByRole("heading", { name: "Overview" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "Scan Metadata" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "Security Audit Metadata" })).toBeTruthy();
expect(screen.queryByText("[legacy.rule] expected: Legacy finding text.")).toBeNull();
expect(screen.queryByText("Review Dimensions")).toBeNull();
expect(screen.queryByText("Purpose & Capability")).toBeNull();
@@ -700,8 +765,7 @@ describe("SecurityScanResults static guidance", () => {
it("shows skills with legacy-only ClawScan analysis in the new ClawScan report shell", () => {
const { container } = render(
<SecurityScannerPage
scanner="clawscan"
<SecurityAuditPage
entity={{
kind: "skill",
title: "Legacy Skill",
@@ -714,10 +778,14 @@ describe("SecurityScanResults static guidance", () => {
);
expect(screen.getByRole("heading", { name: "Legacy Skill" })).toBeTruthy();
expect(screen.getByText(/Audited by ClawScan/i)).toBeTruthy();
expect(
screen.getByText(
"Security checks across static analysis, malware telemetry, and agentic risk",
),
).toBeTruthy();
expect(screen.getByText("Legacy plugin analysis summary.")).toBeTruthy();
expect(screen.getByRole("heading", { name: "Overview" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "Scan Metadata" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "Security Audit Metadata" })).toBeTruthy();
expect(screen.queryByText("Review Dimensions")).toBeNull();
expect(screen.queryByText("Purpose & Capability")).toBeNull();
expect(
@@ -727,8 +795,7 @@ describe("SecurityScanResults static guidance", () => {
it("shows the new ClawScan empty state when no analysis exists yet", () => {
render(
<SecurityScannerPage
scanner="clawscan"
<SecurityAuditPage
entity={{
kind: "skill",
title: "Pending Skill",
@@ -740,11 +807,23 @@ describe("SecurityScanResults static guidance", () => {
);
expect(screen.getByRole("heading", { name: "Pending Skill" })).toBeTruthy();
expect(screen.getByText(/ClawScan audit pending/i)).toBeTruthy();
expect(
screen.getByText(
"Security checks across static analysis, malware telemetry, and agentic risk",
),
).toBeTruthy();
expect(screen.getAllByText("Pending").length).toBeGreaterThan(0);
expect(screen.getByText("No ClawScan analysis has been recorded yet.")).toBeTruthy();
expect(screen.getByText("No risk analysis has been recorded yet.")).toBeTruthy();
expect(
screen.getByText("VirusTotal findings are pending for this skill version."),
).toBeTruthy();
expect(screen.getByText("Static analysis findings are pending for this release.")).toBeTruthy();
expect(screen.queryByText("No VirusTotal findings for this skill version.")).toBeNull();
expect(
screen.queryByText("No static analysis findings were reported for this release."),
).toBeNull();
expect(screen.queryByText("Review Dimensions")).toBeNull();
expect(screen.getByRole("heading", { name: "Overview" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "Scan Metadata" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "Security Audit Metadata" })).toBeTruthy();
});
});
+27 -51
View File
@@ -103,7 +103,6 @@ type SecurityScanResultsProps = {
llmAnalysis?: LlmAnalysis | null;
staticFindings?: StaticFinding[];
capabilityTags?: string[] | null;
scannerBasePath?: string | null;
variant?: "panel" | "badge";
};
@@ -308,7 +307,11 @@ const RISK_LEVEL_BADGE_META: Record<
export function RiskLevelBadge({ level }: { level: ClawScanRiskLevel }) {
const risk = RISK_LEVEL_BADGE_META[level];
return (
<Badge variant={risk.variant} className="scan-risk-level-badge" data-level={risk.level}>
<Badge
variant={risk.variant}
className="scan-risk-level-badge rounded-[4px]"
data-level={risk.level}
>
<span className="scan-risk-level-bars" aria-hidden="true">
<span />
<span />
@@ -323,7 +326,7 @@ function getVisibleAgenticRiskFindings(analysis?: LlmAnalysis | null) {
return (analysis?.agenticRiskFindings ?? []).filter(isVisibleAgenticRiskFinding);
}
export function getVisibleClawScanFindingCount(analysis?: LlmAnalysis | null) {
function getVisibleClawScanFindingCount(analysis?: LlmAnalysis | null) {
return getVisibleAgenticRiskFindings(analysis).length;
}
@@ -341,6 +344,9 @@ function getFindingSeverityBadgeMeta(severity: string): {
return { label: "Critical", variant: "destructive" };
case "high":
return { label: "High", variant: "destructive" };
case "warn":
case "warning":
return { label: "Warn", variant: "warning" };
case "medium":
return { label: "Medium", variant: "warning" };
case "low":
@@ -352,6 +358,11 @@ function getFindingSeverityBadgeMeta(severity: string): {
}
}
export function FindingSeverityBadge({ severity }: { severity: string }) {
const severityBadge = getFindingSeverityBadgeMeta(severity);
return <Badge variant={severityBadge.variant}>{severityBadge.label}</Badge>;
}
function getOwaspAgenticSkillsHref(categoryId: string) {
const match = categoryId.match(/^(?:ASI|AST)(\d{2})$/i);
if (!match) return null;
@@ -381,7 +392,6 @@ function AgenticRiskFindingCard({
const evidence = finding.evidence;
if (!evidence) return null;
const categoryHref = getOwaspAgenticSkillsHref(finding.categoryId);
const severityBadge = getFindingSeverityBadgeMeta(finding.severity);
const title = `${finding.categoryId}: ${finding.categoryLabel}`;
const anchorId = getClawScanFindingAnchorId(finding, index);
@@ -392,9 +402,6 @@ function AgenticRiskFindingCard({
id={anchorId}
>
<div className="agentic-risk-finding-header">
<div className="agentic-risk-finding-badges">
<Badge variant={severityBadge.variant}>{severityBadge.label}</Badge>
</div>
<div className="agentic-risk-finding-title-row">
<a
className="agentic-risk-finding-anchor"
@@ -416,6 +423,9 @@ function AgenticRiskFindingCard({
<div className="agentic-risk-finding-title">{title}</div>
)}
</div>
<div className="agentic-risk-finding-badges">
<FindingSeverityBadge severity={finding.severity} />
</div>
</div>
<div className="agentic-risk-report-rows">
<div className="agentic-risk-report-row">
@@ -670,7 +680,6 @@ export function SecurityScanResults({
llmAnalysis,
staticFindings,
capabilityTags,
scannerBasePath,
variant = "panel",
}: SecurityScanResultsProps) {
const visibleCapabilityTags = (capabilityTags ?? []).filter(Boolean);
@@ -711,30 +720,12 @@ export function SecurityScanResults({
</a>
) : null}
{scannerBasePath ? (
<a
href={`${scannerBasePath}/virustotal`}
className="version-scan-link"
onClick={(event) => event.stopPropagation()}
>
Details
</a>
) : null}
</div>
) : null}
{llmStatusInfo ? (
<div className="version-scan-badge">
<ClawScanIcon className="version-scan-icon version-scan-icon-oc" />
<ScanResultBadge status={llmDisplayStatus} tone="review" />
{scannerBasePath ? (
<a
href={`${scannerBasePath}/clawscan`}
className="version-scan-link"
onClick={(event) => event.stopPropagation()}
>
Details
</a>
) : null}
</div>
) : null}
</>
@@ -778,11 +769,6 @@ export function SecurityScanResults({
View report
</a>
) : null}
{scannerBasePath ? (
<a href={`${scannerBasePath}/virustotal`} className="scan-result-link">
Details
</a>
) : null}
</div>
) : null}
{llmStatusInfo && llmAnalysis ? (
@@ -797,11 +783,6 @@ export function SecurityScanResults({
<RiskLevelBadge level={llmRiskLevel} />
</span>
) : null}
{scannerBasePath ? (
<a href={`${scannerBasePath}/clawscan`} className="scan-result-link">
Details
</a>
) : null}
</div>
) : null}
{llmAnalysis &&
@@ -812,22 +793,17 @@ export function SecurityScanResults({
) : null}
{hasBlockingStaticFindings ? (
<>
{scannerBasePath ? (
<div className="scan-result-row">
<div className="scan-result-scanner">
<span className="scan-result-scanner-name">Static analysis</span>
</div>
<ScanResultBadge
status="malicious"
label={`${blockingStaticFindings.length} blocking finding${
blockingStaticFindings.length === 1 ? "" : "s"
}`}
/>
<a href={`${scannerBasePath}/static-analysis`} className="scan-result-link">
Details
</a>
<div className="scan-result-row">
<div className="scan-result-scanner">
<span className="scan-result-scanner-name">Static analysis</span>
</div>
) : null}
<ScanResultBadge
status="malicious"
label={`${blockingStaticFindings.length} blocking finding${
blockingStaticFindings.length === 1 ? "" : "s"
}`}
/>
</div>
<StaticAnalysisDetail
findings={blockingStaticFindings}
vtStatus={vtStatus}
+103
View File
@@ -0,0 +1,103 @@
import {
getClawScanDisplayStatus,
getVirusTotalDisplayStatus,
type LlmAnalysis,
type StaticFinding,
type VtAnalysis,
} from "./SkillSecurityScanResults";
export type AuditScannerKind = "clawscan" | "virustotal" | "static-analysis";
export const SECURITY_AUDIT_SUBTEXT =
"Security checks across static analysis, malware telemetry, and agentic risk";
export type StaticScanAnalysis = {
status: string;
reasonCodes: string[];
findings: StaticFinding[];
summary: string;
engineVersion: string;
checkedAt: number;
};
type SecurityAuditSignals = {
vtAnalysis?: VtAnalysis | null;
llmAnalysis?: LlmAnalysis | null;
staticScan?: StaticScanAnalysis | null;
suppressScanResults?: boolean;
};
export const AUDIT_SCANNER_LABELS: Record<AuditScannerKind, string> = {
clawscan: "Risk analysis",
virustotal: "VirusTotal",
"static-analysis": "Static analysis",
};
const DEFAULT_AUDIT_SCANNER_ORDER: AuditScannerKind[] = [
"static-analysis",
"virustotal",
"clawscan",
];
function getStaticScanDisplayStatus(staticScan?: StaticScanAnalysis | null) {
const status = staticScan?.status?.trim().toLowerCase();
if (status === "malicious") return "malicious";
if (status === "suspicious") return "review";
if (status === "clean" || status === "benign") return "benign";
if (status) return status;
return "pending";
}
export function getAuditScannerStatus(kind: AuditScannerKind, signals: SecurityAuditSignals) {
if (signals.suppressScanResults) return "cleared";
if (kind === "clawscan") return getClawScanDisplayStatus(signals.llmAnalysis);
if (kind === "virustotal") return getVirusTotalDisplayStatus(signals.vtAnalysis);
return getStaticScanDisplayStatus(signals.staticScan);
}
export function aggregateAuditVerdict(signals: SecurityAuditSignals) {
const statuses = DEFAULT_AUDIT_SCANNER_ORDER.map((kind) => getAuditScannerStatus(kind, signals));
const normalized = statuses.map((status) => status.toLowerCase());
if (normalized.some((status) => status === "malicious")) return "malicious";
if (normalized.some((status) => status === "warn" || status === "warning")) return "warn";
if (normalized.some((status) => status === "suspicious")) return "warn";
if (normalized.some((status) => status === "review")) return "review";
if (normalized.some((status) => status === "error" || status === "failed")) return "error";
if (
normalized.some(
(status) => status === "pending" || status === "loading" || status === "not_found",
)
) {
return "pending";
}
return signals.suppressScanResults ? "cleared" : "benign";
}
export function getSecurityAuditOverviewCopy({
llmAnalysis,
suppressScanResults,
suppressedMessage,
}: {
llmAnalysis?: LlmAnalysis | null;
suppressScanResults?: boolean;
suppressedMessage?: string | null;
}) {
if (suppressScanResults && suppressedMessage?.trim()) return [suppressedMessage.trim()];
return [
llmAnalysis?.summary?.trim() || "No risk analysis has been recorded yet.",
llmAnalysis?.guidance?.trim() || null,
].filter((copy): copy is string => Boolean(copy));
}
export function getAuditScannerOrder() {
return DEFAULT_AUDIT_SCANNER_ORDER;
}
export function getLatestAuditCheckedAt(signals: SecurityAuditSignals) {
const values = [
signals.llmAnalysis?.checkedAt,
signals.vtAnalysis?.checkedAt,
signals.staticScan?.checkedAt,
].filter((value): value is number => typeof value === "number" && Number.isFinite(value));
return values.length ? Math.max(...values) : null;
}
-7
View File
@@ -1,7 +0,0 @@
const DISABLE_DUPLICATE_CLAWSCAN_HASH_SCROLL = `(function(){try{if(location.hash.indexOf('#clawscan-finding-')!==0)return;history.replaceState(Object.assign({},history.state,{__hashScrollIntoViewOptions:false}),'',location.href)}catch(e){}})()`;
export function getClawScanHashScrollScripts(scanner: string) {
return scanner === "clawscan"
? [{ children: DISABLE_DUPLICATE_CLAWSCAN_HASH_SCROLL }]
: undefined;
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import {
buildPluginDetailHref,
buildPluginSecurityHref,
buildPluginSecurityAuditHref,
packageNameFromScopedRoute,
parseScopedPackageName,
} from "./pluginRoutes";
@@ -9,8 +9,8 @@ import {
describe("plugin routes", () => {
it("keeps scoped package routes readable", () => {
expect(buildPluginDetailHref("@openclaw/codex")).toBe("/plugins/@openclaw/codex");
expect(buildPluginSecurityHref("@openclaw/codex", "openclaw")).toBe(
"/plugins/@openclaw/codex/security/openclaw",
expect(buildPluginSecurityAuditHref("@openclaw/codex")).toBe(
"/plugins/@openclaw/codex/security-audit",
);
});
+2 -6
View File
@@ -21,12 +21,8 @@ export function buildPluginDetailHref(name: string) {
)}`;
}
export function buildPluginSecurityBaseHref(name: string) {
return `${buildPluginDetailHref(name)}/security`;
}
export function buildPluginSecurityHref(name: string, scanner: string) {
return `${buildPluginSecurityBaseHref(name)}/${encodeURIComponent(scanner)}`;
export function buildPluginSecurityAuditHref(name: string) {
return `${buildPluginDetailHref(name)}/security-audit`;
}
export function packageNameFromScopedRoute(scope: string, name: string) {
+65
View File
@@ -45,9 +45,12 @@ import { Route as CliAuthRouteImport } from './routes/cli/auth'
import { Route as OwnerSlugRouteImport } from './routes/$owner/$slug'
import { Route as PluginsScopeNameRouteImport } from './routes/plugins/$scope/$name'
import { Route as PluginsNameSettingsRouteImport } from './routes/plugins/$name/settings'
import { Route as PluginsNameSecurityAuditRouteImport } from './routes/plugins/$name/security-audit'
import { Route as PackagesScopeNameRouteImport } from './routes/packages/$scope/$name'
import { Route as OwnerSlugSettingsRouteImport } from './routes/$owner/$slug/settings'
import { Route as OwnerSlugSecurityAuditRouteImport } from './routes/$owner/$slug/security-audit'
import { Route as PluginsScopeNameSettingsRouteImport } from './routes/plugins/$scope/$name/settings'
import { Route as PluginsScopeNameSecurityAuditRouteImport } from './routes/plugins/$scope/$name/security-audit'
import { Route as PluginsNameSecurityScannerRouteImport } from './routes/plugins/$name/security/$scanner'
import { Route as OwnerSlugSecurityScannerRouteImport } from './routes/$owner/$slug/security/$scanner'
import { Route as PluginsScopeNameSecurityScannerRouteImport } from './routes/plugins/$scope/$name/security/$scanner'
@@ -232,6 +235,12 @@ const PluginsNameSettingsRoute = PluginsNameSettingsRouteImport.update({
path: '/settings',
getParentRoute: () => PluginsNameRoute,
} as any)
const PluginsNameSecurityAuditRoute =
PluginsNameSecurityAuditRouteImport.update({
id: '/security-audit',
path: '/security-audit',
getParentRoute: () => PluginsNameRoute,
} as any)
const PackagesScopeNameRoute = PackagesScopeNameRouteImport.update({
id: '/packages/$scope/$name',
path: '/packages/$scope/$name',
@@ -242,12 +251,23 @@ const OwnerSlugSettingsRoute = OwnerSlugSettingsRouteImport.update({
path: '/settings',
getParentRoute: () => OwnerSlugRoute,
} as any)
const OwnerSlugSecurityAuditRoute = OwnerSlugSecurityAuditRouteImport.update({
id: '/security-audit',
path: '/security-audit',
getParentRoute: () => OwnerSlugRoute,
} as any)
const PluginsScopeNameSettingsRoute =
PluginsScopeNameSettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => PluginsScopeNameRoute,
} as any)
const PluginsScopeNameSecurityAuditRoute =
PluginsScopeNameSecurityAuditRouteImport.update({
id: '/security-audit',
path: '/security-audit',
getParentRoute: () => PluginsScopeNameRoute,
} as any)
const PluginsNameSecurityScannerRoute =
PluginsNameSecurityScannerRouteImport.update({
id: '/security/$scanner',
@@ -302,12 +322,15 @@ export interface FileRoutesByFullPath {
'/skills/': typeof SkillsIndexRoute
'/souls/': typeof SoulsIndexRoute
'/users/': typeof UsersIndexRoute
'/$owner/$slug/security-audit': typeof OwnerSlugSecurityAuditRoute
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
'/packages/$scope/$name': typeof PackagesScopeNameRoute
'/plugins/$name/security-audit': typeof PluginsNameSecurityAuditRoute
'/plugins/$name/settings': typeof PluginsNameSettingsRoute
'/plugins/$scope/$name': typeof PluginsScopeNameRouteWithChildren
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
'/plugins/$scope/$name/security-audit': typeof PluginsScopeNameSecurityAuditRoute
'/plugins/$scope/$name/settings': typeof PluginsScopeNameSettingsRoute
'/plugins/$scope/$name/security/$scanner': typeof PluginsScopeNameSecurityScannerRoute
}
@@ -346,12 +369,15 @@ export interface FileRoutesByTo {
'/skills': typeof SkillsIndexRoute
'/souls': typeof SoulsIndexRoute
'/users': typeof UsersIndexRoute
'/$owner/$slug/security-audit': typeof OwnerSlugSecurityAuditRoute
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
'/packages/$scope/$name': typeof PackagesScopeNameRoute
'/plugins/$name/security-audit': typeof PluginsNameSecurityAuditRoute
'/plugins/$name/settings': typeof PluginsNameSettingsRoute
'/plugins/$scope/$name': typeof PluginsScopeNameRouteWithChildren
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
'/plugins/$scope/$name/security-audit': typeof PluginsScopeNameSecurityAuditRoute
'/plugins/$scope/$name/settings': typeof PluginsScopeNameSettingsRoute
'/plugins/$scope/$name/security/$scanner': typeof PluginsScopeNameSecurityScannerRoute
}
@@ -391,12 +417,15 @@ export interface FileRoutesById {
'/skills/': typeof SkillsIndexRoute
'/souls/': typeof SoulsIndexRoute
'/users/': typeof UsersIndexRoute
'/$owner/$slug/security-audit': typeof OwnerSlugSecurityAuditRoute
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
'/packages/$scope/$name': typeof PackagesScopeNameRoute
'/plugins/$name/security-audit': typeof PluginsNameSecurityAuditRoute
'/plugins/$name/settings': typeof PluginsNameSettingsRoute
'/plugins/$scope/$name': typeof PluginsScopeNameRouteWithChildren
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
'/plugins/$scope/$name/security-audit': typeof PluginsScopeNameSecurityAuditRoute
'/plugins/$scope/$name/settings': typeof PluginsScopeNameSettingsRoute
'/plugins/$scope/$name/security/$scanner': typeof PluginsScopeNameSecurityScannerRoute
}
@@ -437,12 +466,15 @@ export interface FileRouteTypes {
| '/skills/'
| '/souls/'
| '/users/'
| '/$owner/$slug/security-audit'
| '/$owner/$slug/settings'
| '/packages/$scope/$name'
| '/plugins/$name/security-audit'
| '/plugins/$name/settings'
| '/plugins/$scope/$name'
| '/$owner/$slug/security/$scanner'
| '/plugins/$name/security/$scanner'
| '/plugins/$scope/$name/security-audit'
| '/plugins/$scope/$name/settings'
| '/plugins/$scope/$name/security/$scanner'
fileRoutesByTo: FileRoutesByTo
@@ -481,12 +513,15 @@ export interface FileRouteTypes {
| '/skills'
| '/souls'
| '/users'
| '/$owner/$slug/security-audit'
| '/$owner/$slug/settings'
| '/packages/$scope/$name'
| '/plugins/$name/security-audit'
| '/plugins/$name/settings'
| '/plugins/$scope/$name'
| '/$owner/$slug/security/$scanner'
| '/plugins/$name/security/$scanner'
| '/plugins/$scope/$name/security-audit'
| '/plugins/$scope/$name/settings'
| '/plugins/$scope/$name/security/$scanner'
id:
@@ -525,12 +560,15 @@ export interface FileRouteTypes {
| '/skills/'
| '/souls/'
| '/users/'
| '/$owner/$slug/security-audit'
| '/$owner/$slug/settings'
| '/packages/$scope/$name'
| '/plugins/$name/security-audit'
| '/plugins/$name/settings'
| '/plugins/$scope/$name'
| '/$owner/$slug/security/$scanner'
| '/plugins/$name/security/$scanner'
| '/plugins/$scope/$name/security-audit'
| '/plugins/$scope/$name/settings'
| '/plugins/$scope/$name/security/$scanner'
fileRoutesById: FileRoutesById
@@ -828,6 +866,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PluginsNameSettingsRouteImport
parentRoute: typeof PluginsNameRoute
}
'/plugins/$name/security-audit': {
id: '/plugins/$name/security-audit'
path: '/security-audit'
fullPath: '/plugins/$name/security-audit'
preLoaderRoute: typeof PluginsNameSecurityAuditRouteImport
parentRoute: typeof PluginsNameRoute
}
'/packages/$scope/$name': {
id: '/packages/$scope/$name'
path: '/packages/$scope/$name'
@@ -842,6 +887,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof OwnerSlugSettingsRouteImport
parentRoute: typeof OwnerSlugRoute
}
'/$owner/$slug/security-audit': {
id: '/$owner/$slug/security-audit'
path: '/security-audit'
fullPath: '/$owner/$slug/security-audit'
preLoaderRoute: typeof OwnerSlugSecurityAuditRouteImport
parentRoute: typeof OwnerSlugRoute
}
'/plugins/$scope/$name/settings': {
id: '/plugins/$scope/$name/settings'
path: '/settings'
@@ -849,6 +901,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PluginsScopeNameSettingsRouteImport
parentRoute: typeof PluginsScopeNameRoute
}
'/plugins/$scope/$name/security-audit': {
id: '/plugins/$scope/$name/security-audit'
path: '/security-audit'
fullPath: '/plugins/$scope/$name/security-audit'
preLoaderRoute: typeof PluginsScopeNameSecurityAuditRouteImport
parentRoute: typeof PluginsScopeNameRoute
}
'/plugins/$name/security/$scanner': {
id: '/plugins/$name/security/$scanner'
path: '/security/$scanner'
@@ -874,11 +933,13 @@ declare module '@tanstack/react-router' {
}
interface OwnerSlugRouteChildren {
OwnerSlugSecurityAuditRoute: typeof OwnerSlugSecurityAuditRoute
OwnerSlugSettingsRoute: typeof OwnerSlugSettingsRoute
OwnerSlugSecurityScannerRoute: typeof OwnerSlugSecurityScannerRoute
}
const OwnerSlugRouteChildren: OwnerSlugRouteChildren = {
OwnerSlugSecurityAuditRoute: OwnerSlugSecurityAuditRoute,
OwnerSlugSettingsRoute: OwnerSlugSettingsRoute,
OwnerSlugSecurityScannerRoute: OwnerSlugSecurityScannerRoute,
}
@@ -888,11 +949,13 @@ const OwnerSlugRouteWithChildren = OwnerSlugRoute._addFileChildren(
)
interface PluginsNameRouteChildren {
PluginsNameSecurityAuditRoute: typeof PluginsNameSecurityAuditRoute
PluginsNameSettingsRoute: typeof PluginsNameSettingsRoute
PluginsNameSecurityScannerRoute: typeof PluginsNameSecurityScannerRoute
}
const PluginsNameRouteChildren: PluginsNameRouteChildren = {
PluginsNameSecurityAuditRoute: PluginsNameSecurityAuditRoute,
PluginsNameSettingsRoute: PluginsNameSettingsRoute,
PluginsNameSecurityScannerRoute: PluginsNameSecurityScannerRoute,
}
@@ -902,11 +965,13 @@ const PluginsNameRouteWithChildren = PluginsNameRoute._addFileChildren(
)
interface PluginsScopeNameRouteChildren {
PluginsScopeNameSecurityAuditRoute: typeof PluginsScopeNameSecurityAuditRoute
PluginsScopeNameSettingsRoute: typeof PluginsScopeNameSettingsRoute
PluginsScopeNameSecurityScannerRoute: typeof PluginsScopeNameSecurityScannerRoute
}
const PluginsScopeNameRouteChildren: PluginsScopeNameRouteChildren = {
PluginsScopeNameSecurityAuditRoute: PluginsScopeNameSecurityAuditRoute,
PluginsScopeNameSettingsRoute: PluginsScopeNameSettingsRoute,
PluginsScopeNameSecurityScannerRoute: PluginsScopeNameSecurityScannerRoute,
}
+1
View File
@@ -93,6 +93,7 @@ function OwnerSkill() {
const pathname = useRouterState({ select: (state) => state.location.pathname });
if (
pathname.includes(`/${encodeURIComponent(slug)}/security/`) ||
pathname.endsWith(`/${encodeURIComponent(slug)}/security-audit`) ||
pathname.endsWith(`/${encodeURIComponent(slug)}/settings`)
) {
return <Outlet />;
+121
View File
@@ -0,0 +1,121 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import { useQuery } from "convex/react";
import { api } from "../../../../convex/_generated/api";
import {
SecurityAuditPage,
SecurityAuditPageSkeleton,
} from "../../../components/SecurityAuditPage";
import { buildSkillMeta } from "../../../lib/og";
import { isAdmin } from "../../../lib/roles";
import { fetchSkillPageData } from "../../../lib/skillPage";
import { useAuthStatus } from "../../../lib/useAuthStatus";
export const Route = createFileRoute("/$owner/$slug/security-audit")({
beforeLoad: ({ params }) => {
const isHandle = /^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/.test(params.owner);
const isOwnerId = params.owner.startsWith("users:") || params.owner.startsWith("publishers:");
if (!isHandle && !isOwnerId) throw notFound();
},
loader: async ({ params }) => {
const data = await fetchSkillPageData(params.slug);
const canonicalOwner = data.initialData?.result?.owner?.handle ?? null;
const canonicalSlug = data.initialData?.result?.resolvedSlug ?? params.slug;
if (canonicalOwner && (canonicalOwner !== params.owner || canonicalSlug !== params.slug)) {
throw redirect({
to: "/$owner/$slug/security-audit",
params: {
owner: canonicalOwner,
slug: canonicalSlug,
},
replace: true,
});
}
return {
owner: data?.owner ?? params.owner,
displayName: data?.displayName ?? null,
summary: data?.summary ?? null,
version: data?.version ?? null,
initialData: data.initialData,
};
},
head: ({ params, loaderData }) => {
const meta = buildSkillMeta({
slug: params.slug,
owner: loaderData?.owner ?? params.owner,
displayName: loaderData?.displayName,
summary: loaderData?.summary,
version: loaderData?.version ?? null,
});
return {
meta: [
{ title: `Security audit · ${meta.title}` },
{
name: "description",
content: `Security audit details for ${loaderData?.displayName ?? params.slug}.`,
},
],
};
},
component: SkillSecurityAuditRoute,
});
function SkillSecurityAuditRoute() {
const { owner, slug } = Route.useParams();
const { initialData } = Route.useLoaderData();
const liveResult = useQuery(api.skills.getBySlug, { slug });
const { me } = useAuthStatus();
const myPublishers = useQuery(api.publishers.listMine, me ? {} : "skip") as
| Array<{ publisher: { _id: string }; role: string }>
| undefined;
const result = liveResult === undefined ? initialData?.result : liveResult;
const skill = result?.skill;
const latestVersion = result?.latestVersion;
if (result === undefined) {
return <SecurityAuditPageSkeleton />;
}
if (!skill || !latestVersion) {
return (
<main className="section">
<div className="card">Security audit is unavailable for this skill.</div>
</main>
);
}
const ownerSegment = result?.owner?.handle ?? result?.owner?._id ?? owner;
const myManagePublisherIds = new Set(
(Array.isArray(myPublishers) ? myPublishers : [])
.filter((entry) => entry.role === "owner" || entry.role === "admin")
.map((entry) => entry.publisher._id),
);
const canManageArtifact =
Boolean(me && skill && me._id === skill.ownerUserId) ||
Boolean(skill?.ownerPublisherId && myManagePublisherIds.has(skill.ownerPublisherId)) ||
isAdmin(me);
const settingsHref = `/${encodeURIComponent(ownerSegment)}/${encodeURIComponent(slug)}/settings`;
return (
<SecurityAuditPage
entity={{
kind: "skill",
title: skill.displayName,
name: slug,
version: latestVersion.version,
owner: result?.owner ?? null,
ownerUserId: skill.ownerUserId,
ownerPublisherId: skill.ownerPublisherId ?? null,
detailPath: `/${encodeURIComponent(ownerSegment)}/${encodeURIComponent(slug)}`,
}}
sha256hash={latestVersion.sha256hash ?? null}
vtAnalysis={latestVersion.vtAnalysis ?? null}
llmAnalysis={latestVersion.llmAnalysis ?? null}
staticScan={latestVersion.staticScan ?? null}
clawScanNote={latestVersion.clawScanNote ?? null}
canManageArtifact={canManageArtifact}
settingsHref={canManageArtifact ? settingsHref : null}
/>
);
}
+8 -141
View File
@@ -1,150 +1,17 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import { useQuery } from "convex/react";
import { api } from "../../../../../convex/_generated/api";
import {
SecurityScannerPage,
SecurityScannerPageSkeleton,
type ScannerSlug,
} from "../../../../components/SecurityScannerPage";
import { getClawScanHashScrollScripts } from "../../../../lib/clawScanHashScroll";
import { buildSkillMeta } from "../../../../lib/og";
import { isAdmin } from "../../../../lib/roles";
import { fetchSkillPageData } from "../../../../lib/skillPage";
import { useAuthStatus } from "../../../../lib/useAuthStatus";
const SCANNERS = new Set<ScannerSlug>(["virustotal", "clawscan", "static-analysis"]);
function parseScanner(scanner: string): ScannerSlug {
if (SCANNERS.has(scanner as ScannerSlug)) return scanner as ScannerSlug;
throw notFound();
}
export const Route = createFileRoute("/$owner/$slug/security/$scanner")({
beforeLoad: ({ params }) => {
const isHandle = /^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/.test(params.owner);
const isOwnerId = params.owner.startsWith("users:") || params.owner.startsWith("publishers:");
if (!isHandle && !isOwnerId) {
throw notFound();
}
if (params.scanner === "openclaw") {
throw redirect({
to: "/$owner/$slug/security/$scanner",
params: { owner: params.owner, slug: params.slug, scanner: "clawscan" },
replace: true,
});
}
parseScanner(params.scanner);
},
loader: async ({ params }) => {
const data = await fetchSkillPageData(params.slug);
const canonicalOwner = data.initialData?.result?.owner?.handle ?? null;
const canonicalSlug = data.initialData?.result?.resolvedSlug ?? params.slug;
if (canonicalOwner && (canonicalOwner !== params.owner || canonicalSlug !== params.slug)) {
throw redirect({
to: "/$owner/$slug/security/$scanner",
params: {
owner: canonicalOwner,
slug: canonicalSlug,
scanner: params.scanner === "openclaw" ? "clawscan" : params.scanner,
},
replace: true,
});
}
return {
owner: data?.owner ?? params.owner,
displayName: data?.displayName ?? null,
summary: data?.summary ?? null,
version: data?.version ?? null,
initialData: data.initialData,
};
},
scripts: ({ params }) => getClawScanHashScrollScripts(params.scanner),
head: ({ params, loaderData }) => {
const scanner = parseScanner(params.scanner);
const scannerLabel =
scanner === "virustotal"
? "VirusTotal"
: scanner === "clawscan"
? "ClawScan"
: "Static analysis";
const meta = buildSkillMeta({
slug: params.slug,
owner: loaderData?.owner ?? params.owner,
displayName: loaderData?.displayName,
summary: loaderData?.summary,
version: loaderData?.version ?? null,
if (!isHandle && !isOwnerId) throw notFound();
throw redirect({
to: "/$owner/$slug/security-audit",
params: {
owner: params.owner,
slug: params.slug,
},
replace: true,
});
return {
meta: [
{ title: `${scannerLabel} security · ${meta.title}` },
{
name: "description",
content: `${scannerLabel} security details for ${loaderData?.displayName ?? params.slug}.`,
},
],
};
},
component: SkillSecurityScannerRoute,
});
function SkillSecurityScannerRoute() {
const { owner, slug, scanner } = Route.useParams();
const { initialData } = Route.useLoaderData();
const liveResult = useQuery(api.skills.getBySlug, { slug });
const { me } = useAuthStatus();
const myPublishers = useQuery(api.publishers.listMine, me ? {} : "skip") as
| Array<{ publisher: { _id: string }; role: string }>
| undefined;
const result = liveResult === undefined ? initialData?.result : liveResult;
const skill = result?.skill;
const latestVersion = result?.latestVersion;
if (result === undefined) {
return <SecurityScannerPageSkeleton />;
}
if (!skill || !latestVersion) {
return (
<main className="section">
<div className="card">Security details are unavailable for this skill.</div>
</main>
);
}
const ownerSegment = result?.owner?.handle ?? result?.owner?._id ?? owner;
const myManagePublisherIds = new Set(
(Array.isArray(myPublishers) ? myPublishers : [])
.filter((entry) => entry.role === "owner" || entry.role === "admin")
.map((entry) => entry.publisher._id),
);
const canManageArtifact =
Boolean(me && skill && me._id === skill.ownerUserId) ||
Boolean(skill?.ownerPublisherId && myManagePublisherIds.has(skill.ownerPublisherId)) ||
isAdmin(me);
const settingsHref = `/${encodeURIComponent(ownerSegment)}/${encodeURIComponent(slug)}/settings`;
return (
<SecurityScannerPage
scanner={parseScanner(scanner)}
entity={{
kind: "skill",
title: skill.displayName,
name: slug,
version: latestVersion.version,
owner: result?.owner ?? null,
ownerUserId: skill.ownerUserId,
ownerPublisherId: skill.ownerPublisherId ?? null,
detailPath: `/${encodeURIComponent(ownerSegment)}/${encodeURIComponent(slug)}`,
}}
sha256hash={latestVersion.sha256hash ?? null}
vtAnalysis={latestVersion.vtAnalysis ?? null}
llmAnalysis={latestVersion.llmAnalysis ?? null}
staticScan={latestVersion.staticScan ?? null}
clawScanNote={latestVersion.clawScanNote ?? null}
canManageArtifact={canManageArtifact}
settingsHref={canManageArtifact ? settingsHref : null}
/>
);
}
+33 -15
View File
@@ -4,7 +4,10 @@ import { AlertTriangle, Download, Settings, Upload } from "lucide-react";
import { useState, type ReactNode } from "react";
import { api } from "../../../convex/_generated/api";
import { DetailHero, DetailPageShell } from "../../components/DetailPageShell";
import { DetailSecuritySummary } from "../../components/DetailSecuritySummary";
import {
DetailSecuritySummary,
DetailSecuritySummaryLabel,
} from "../../components/DetailSecuritySummary";
import { EmptyState } from "../../components/EmptyState";
import { InstallCopyButton } from "../../components/InstallCopyButton";
import { Container } from "../../components/layout/Container";
@@ -30,7 +33,7 @@ import {
import { familyLabel } from "../../lib/packageLabels";
import {
buildPluginDetailHref,
buildPluginSecurityBaseHref,
buildPluginSecurityAuditHref,
parseScopedPackageName,
} from "../../lib/pluginRoutes";
import { useAuthStatus } from "../../lib/useAuthStatus";
@@ -142,11 +145,16 @@ export const Route = createFileRoute("/plugins/$name")({
beforeLoad: ({ location, params }) => {
if (parseScopedPackageName(params.name)) {
const encodedSecurityPrefix = `/plugins/${encodeURIComponent(params.name)}/security/`;
const encodedSecurityAuditPath = `/plugins/${encodeURIComponent(params.name)}/security-audit`;
if (location.pathname.startsWith(encodedSecurityPrefix)) {
throw redirect({
href: `${buildPluginSecurityBaseHref(params.name)}/${location.pathname.slice(
encodedSecurityPrefix.length,
)}`,
href: buildPluginSecurityAuditHref(params.name),
statusCode: 308,
});
}
if (location.pathname === encodedSecurityAuditPath) {
throw redirect({
href: buildPluginSecurityAuditHref(params.name),
statusCode: 308,
});
}
@@ -338,7 +346,10 @@ export function PluginDetailPage({
const { detail, version, readme, rateLimited } = loaderData;
const pathname = useRouterState({ select: (state) => state.location.pathname });
const { me } = useAuthStatus();
const isNestedPluginRoute = pathname.includes("/security/") || pathname.endsWith("/settings");
const isNestedPluginRoute =
pathname.includes("/security/") ||
pathname.endsWith("/security-audit") ||
pathname.endsWith("/settings");
const settingsCandidateNames = getOpenClawPackageCandidateNames(name);
const settingsLookupName = detail.package?.name ?? settingsCandidateNames[0] ?? name;
const settings = useQuery(
@@ -607,10 +618,19 @@ export function PluginDetailPage({
const hasSourceMetadata = Boolean(
sourceRepoLink ||
ownerMetadataValue ||
latestRelease ||
executesCodeValue ||
pkg.latestVersion ||
tagMetadataValue,
);
const securitySummary = latestRelease ? (
<DetailSecuritySummary
auditHref={buildPluginSecurityAuditHref(name)}
vtAnalysis={latestRelease.vtAnalysis ?? null}
llmAnalysis={latestRelease.llmAnalysis ?? null}
staticScan={latestRelease.staticScan ?? null}
/>
) : null;
return (
<main className="section detail-page-section">
@@ -655,6 +675,13 @@ export function PluginDetailPage({
blocks={[
{ label: "Repository", value: sourceRepoLink },
{ label: "Owner", value: ownerMetadataValue },
securitySummary
? {
key: "security-audit",
label: <DetailSecuritySummaryLabel />,
value: securitySummary,
}
: { label: "", value: null },
{ label: "Executes code", value: executesCodeValue },
{
grid: [
@@ -701,15 +728,6 @@ export function PluginDetailPage({
</div>
}
>
{latestRelease ? (
<DetailSecuritySummary
scannerBasePath={buildPluginSecurityBaseHref(name)}
sha256hash={latestRelease.sha256hash ?? null}
vtAnalysis={latestRelease.vtAnalysis ?? null}
llmAnalysis={latestRelease.llmAnalysis ?? null}
staticScan={latestRelease.staticScan ?? null}
/>
) : null}
<Card className="skill-install-command-card">
<CardHeader>
<CardTitle>Install</CardTitle>
+156
View File
@@ -0,0 +1,156 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useQuery } from "convex/react";
import { api } from "../../../../convex/_generated/api";
import { SecurityAuditPage } from "../../../components/SecurityAuditPage";
import { getOpenClawPackageCandidateNames } from "../../../lib/openClawExtensionSlugs";
import {
fetchPackageDetail,
fetchPackageVersion,
isRateLimitedPackageApiError,
type PackageDetailResponse,
type PackageVersionDetail,
} from "../../../lib/packageApi";
import {
buildPluginDetailHref,
buildPluginSecurityAuditHref,
parseScopedPackageName,
} from "../../../lib/pluginRoutes";
export type PluginSecurityAuditLoaderData = {
detail: PackageDetailResponse;
version: PackageVersionDetail | null;
resolvedName: string;
rateLimited: boolean;
};
export async function loadPluginSecurityAudit(
requestedName: string,
): Promise<PluginSecurityAuditLoaderData> {
const candidateNames = getOpenClawPackageCandidateNames(requestedName);
let resolvedName = requestedName;
let detail: PackageDetailResponse = { package: null, owner: null };
for (const candidateName of candidateNames) {
try {
const candidateDetail = await fetchPackageDetail(candidateName);
if (candidateDetail.package) {
detail = candidateDetail;
resolvedName = candidateName;
break;
}
detail = candidateDetail;
} catch (error) {
if (isRateLimitedPackageApiError(error)) {
return { detail, version: null, resolvedName, rateLimited: true };
}
throw error;
}
}
if (!detail.package?.latestVersion) {
return { detail, version: null, resolvedName, rateLimited: false };
}
try {
const version = await fetchPackageVersion(resolvedName, detail.package.latestVersion);
return { detail, version, resolvedName, rateLimited: false };
} catch (error) {
if (isRateLimitedPackageApiError(error)) {
return { detail, version: null, resolvedName, rateLimited: true };
}
throw error;
}
}
export function pluginSecurityAuditHead(name: string, loaderData?: PluginSecurityAuditLoaderData) {
return {
meta: [
{
title: `Security audit · ${loaderData?.detail.package?.displayName ?? name}`,
},
{
name: "description",
content: `Security audit details for ${loaderData?.detail.package?.displayName ?? name}.`,
},
],
};
}
export const Route = createFileRoute("/plugins/$name/security-audit")({
beforeLoad: ({ params }) => {
if (parseScopedPackageName(params.name)) {
throw redirect({
href: buildPluginSecurityAuditHref(params.name),
statusCode: 308,
});
}
},
loader: async ({ params }) => loadPluginSecurityAudit(params.name),
head: ({ params, loaderData }) => pluginSecurityAuditHead(params.name, loaderData),
component: PluginSecurityAuditRoute,
});
function PluginSecurityAuditRoute() {
const { name } = Route.useParams();
return (
<PluginSecurityAuditPage
name={name}
loaderData={Route.useLoaderData() as PluginSecurityAuditLoaderData}
/>
);
}
export function PluginSecurityAuditPage({
name,
loaderData,
}: {
name: string;
loaderData: PluginSecurityAuditLoaderData;
}) {
const { detail, version, resolvedName, rateLimited } = loaderData;
const pkg = detail.package;
const release = version?.version ?? null;
const settings = useQuery(api.packages.getClawScanNoteSettings, {
name: resolvedName,
candidateNames: getOpenClawPackageCandidateNames(name),
});
if (rateLimited) {
return (
<main className="section">
<div className="card">Plugin security audit is temporarily unavailable.</div>
</main>
);
}
if (!pkg || !release) {
return (
<main className="section">
<div className="card">Security audit is unavailable for this plugin.</div>
</main>
);
}
return (
<SecurityAuditPage
entity={{
kind: "plugin",
title: pkg.displayName,
name: resolvedName,
version: release.version,
owner: detail.owner ?? null,
ownerUserId: null,
ownerPublisherId: null,
detailPath: buildPluginDetailHref(name),
}}
sha256hash={release.sha256hash ?? null}
vtAnalysis={release.vtAnalysis ?? null}
llmAnalysis={release.llmAnalysis ?? null}
staticScan={release.staticScan ?? null}
clawScanNote={release.clawScanNote ?? null}
canManageArtifact={Boolean(settings)}
settingsHref={settings ? `${buildPluginDetailHref(resolvedName)}/settings` : null}
/>
);
}
+6 -182
View File
@@ -1,187 +1,11 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import { useQuery } from "convex/react";
import { api } from "../../../../../convex/_generated/api";
import { SecurityScannerPage, type ScannerSlug } from "../../../../components/SecurityScannerPage";
import { getClawScanHashScrollScripts } from "../../../../lib/clawScanHashScroll";
import { getOpenClawPackageCandidateNames } from "../../../../lib/openClawExtensionSlugs";
import {
fetchPackageDetail,
fetchPackageVersion,
isRateLimitedPackageApiError,
type PackageDetailResponse,
type PackageVersionDetail,
} from "../../../../lib/packageApi";
import {
buildPluginDetailHref,
buildPluginSecurityHref,
parseScopedPackageName,
} from "../../../../lib/pluginRoutes";
const SCANNERS = new Set<ScannerSlug>(["virustotal", "clawscan", "static-analysis"]);
export type PluginSecurityLoaderData = {
detail: PackageDetailResponse;
version: PackageVersionDetail | null;
resolvedName: string;
rateLimited: boolean;
};
export function parsePluginSecurityScanner(scanner: string): ScannerSlug {
if (SCANNERS.has(scanner as ScannerSlug)) return scanner as ScannerSlug;
throw notFound();
}
export async function loadPluginSecurity(requestedName: string): Promise<PluginSecurityLoaderData> {
const candidateNames = getOpenClawPackageCandidateNames(requestedName);
let resolvedName = requestedName;
let detail: PackageDetailResponse = { package: null, owner: null };
for (const candidateName of candidateNames) {
try {
const candidateDetail = await fetchPackageDetail(candidateName);
if (candidateDetail.package) {
detail = candidateDetail;
resolvedName = candidateName;
break;
}
detail = candidateDetail;
} catch (error) {
if (isRateLimitedPackageApiError(error)) {
return { detail, version: null, resolvedName, rateLimited: true };
}
throw error;
}
}
if (!detail.package?.latestVersion) {
return { detail, version: null, resolvedName, rateLimited: false };
}
try {
const version = await fetchPackageVersion(resolvedName, detail.package.latestVersion);
return { detail, version, resolvedName, rateLimited: false };
} catch (error) {
if (isRateLimitedPackageApiError(error)) {
return { detail, version: null, resolvedName, rateLimited: true };
}
throw error;
}
}
export function pluginSecurityHead(
name: string,
scannerParam: string,
loaderData?: PluginSecurityLoaderData,
) {
const scanner = parsePluginSecurityScanner(scannerParam);
const scannerLabel =
scanner === "virustotal"
? "VirusTotal"
: scanner === "clawscan"
? "ClawScan"
: "Static analysis";
return {
meta: [
{
title: `${scannerLabel} security · ${loaderData?.detail.package?.displayName ?? name}`,
},
{
name: "description",
content: `${scannerLabel} security details for ${
loaderData?.detail.package?.displayName ?? name
}.`,
},
],
};
}
import { createFileRoute, redirect } from "@tanstack/react-router";
import { buildPluginSecurityAuditHref } from "../../../../lib/pluginRoutes";
export const Route = createFileRoute("/plugins/$name/security/$scanner")({
beforeLoad: ({ params }) => {
if (params.scanner === "openclaw") {
throw redirect({
href: buildPluginSecurityHref(params.name, "clawscan"),
statusCode: 308,
});
}
parsePluginSecurityScanner(params.scanner);
if (parseScopedPackageName(params.name)) {
throw redirect({
href: buildPluginSecurityHref(params.name, params.scanner),
statusCode: 308,
});
}
throw redirect({
href: buildPluginSecurityAuditHref(params.name),
statusCode: 308,
});
},
loader: async ({ params }) => loadPluginSecurity(params.name),
scripts: ({ params }) => getClawScanHashScrollScripts(params.scanner),
head: ({ params, loaderData }) => pluginSecurityHead(params.name, params.scanner, loaderData),
component: PluginSecurityScannerRoute,
});
function PluginSecurityScannerRoute() {
const { name, scanner } = Route.useParams();
return (
<PluginSecurityScannerPage
name={name}
scanner={scanner}
loaderData={Route.useLoaderData() as PluginSecurityLoaderData}
/>
);
}
export function PluginSecurityScannerPage({
name,
scanner,
loaderData,
}: {
name: string;
scanner: string;
loaderData: PluginSecurityLoaderData;
}) {
const { detail, version, resolvedName, rateLimited } = loaderData;
const pkg = detail.package;
const release = version?.version ?? null;
const settings = useQuery(api.packages.getClawScanNoteSettings, {
name: resolvedName,
candidateNames: getOpenClawPackageCandidateNames(name),
});
if (rateLimited) {
return (
<main className="section">
<div className="card">Plugin security details are temporarily unavailable.</div>
</main>
);
}
if (!pkg || !release) {
return (
<main className="section">
<div className="card">Security details are unavailable for this plugin.</div>
</main>
);
}
return (
<SecurityScannerPage
scanner={parsePluginSecurityScanner(scanner)}
entity={{
kind: "plugin",
title: pkg.displayName,
name: resolvedName,
version: release.version,
owner: detail.owner ?? null,
ownerUserId: null,
ownerPublisherId: null,
detailPath: buildPluginDetailHref(name),
}}
sha256hash={release.sha256hash ?? null}
vtAnalysis={release.vtAnalysis ?? null}
llmAnalysis={release.llmAnalysis ?? null}
staticScan={release.staticScan ?? null}
clawScanNote={release.clawScanNote ?? null}
canManageArtifact={Boolean(settings)}
settingsHref={settings ? `${buildPluginDetailHref(resolvedName)}/settings` : null}
/>
);
}
@@ -0,0 +1,34 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
import {
loadPluginSecurityAudit,
PluginSecurityAuditPage,
pluginSecurityAuditHead,
type PluginSecurityAuditLoaderData,
} from "../../$name/security-audit";
import { packageNameFromScopedRoute } from "../../../../lib/pluginRoutes";
function packageNameFromParams(params: { scope: string; name: string }) {
const packageName = packageNameFromScopedRoute(params.scope, params.name);
if (!packageName) throw notFound();
return packageName;
}
export const Route = createFileRoute("/plugins/$scope/$name/security-audit")({
beforeLoad: ({ params }) => {
packageNameFromParams(params);
},
loader: async ({ params }) => loadPluginSecurityAudit(packageNameFromParams(params)),
head: ({ params, loaderData }) =>
pluginSecurityAuditHead(packageNameFromParams(params), loaderData),
component: ScopedPluginSecurityAuditRoute,
});
function ScopedPluginSecurityAuditRoute() {
const params = Route.useParams();
return (
<PluginSecurityAuditPage
name={packageNameFromParams(params)}
loaderData={Route.useLoaderData() as PluginSecurityAuditLoaderData}
/>
);
}
@@ -1,14 +1,6 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import {
loadPluginSecurity,
parsePluginSecurityScanner,
PluginSecurityScannerPage,
pluginSecurityHead,
type PluginSecurityLoaderData,
} from "../../../$name/security/$scanner";
import { getClawScanHashScrollScripts } from "../../../../../lib/clawScanHashScroll";
import {
buildPluginSecurityHref,
buildPluginSecurityAuditHref,
packageNameFromScopedRoute,
} from "../../../../../lib/pluginRoutes";
@@ -20,29 +12,9 @@ function packageNameFromParams(params: { scope: string; name: string }) {
export const Route = createFileRoute("/plugins/$scope/$name/security/$scanner")({
beforeLoad: ({ params }) => {
const packageName = packageNameFromParams(params);
if (params.scanner === "openclaw") {
throw redirect({
href: buildPluginSecurityHref(packageName, "clawscan"),
statusCode: 308,
});
}
parsePluginSecurityScanner(params.scanner);
throw redirect({
href: buildPluginSecurityAuditHref(packageNameFromParams(params)),
statusCode: 308,
});
},
loader: async ({ params }) => loadPluginSecurity(packageNameFromParams(params)),
scripts: ({ params }) => getClawScanHashScrollScripts(params.scanner),
head: ({ params, loaderData }) =>
pluginSecurityHead(packageNameFromParams(params), params.scanner, loaderData),
component: ScopedPluginSecurityScannerRoute,
});
function ScopedPluginSecurityScannerRoute() {
const params = Route.useParams();
return (
<PluginSecurityScannerPage
name={packageNameFromParams(params)}
scanner={params.scanner}
loaderData={Route.useLoaderData() as PluginSecurityLoaderData}
/>
);
}
+257 -192
View File
@@ -1293,7 +1293,7 @@ code {
.security-report-main {
min-width: 0;
display: grid;
gap: 60px;
gap: 90px;
}
.security-report-header {
@@ -1357,6 +1357,10 @@ code {
gap: 28px;
}
.security-report-panel-compact {
gap: 12px;
}
.security-report-panel-body .agentic-risk-findings > .scan-findings-title {
margin: 0;
font-family: var(--font-display);
@@ -1372,11 +1376,66 @@ code {
border-bottom: 1px solid var(--line);
}
.security-report-panel-header .skill-install-panel-title {
font-size: var(--font-size-fs-xl);
line-height: 1.15;
font-weight: 760;
}
.security-report-panel-title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
.security-report-title-info-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: 1px solid transparent;
border-radius: 999px;
color: var(--ink-soft);
text-decoration: none;
transition:
border-color 0.16s ease,
color 0.16s ease,
background-color 0.16s ease;
}
.security-report-title-info-link:hover,
.security-report-title-info-link:focus-visible {
border-color: transparent;
background: transparent;
color: var(--ink);
outline: none;
}
.security-report-title-tooltip {
max-width: 320px;
line-height: 1.45;
}
.security-audit-scanner-header {
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
gap: 10px 12px;
}
.security-report-panel-body .clawscan-risk-review {
gap: 26px;
margin-top: 0;
}
.security-report-panel-body-findings {
padding-top: 12px;
}
.publisher-note-prompt {
display: flex;
align-items: center;
@@ -1452,16 +1511,12 @@ code {
}
.security-report-panel-body .agentic-risk-findings {
gap: 64px;
}
.security-report-panel-body .agentic-risk-findings > .scan-findings-title {
margin-bottom: -2px;
gap: 40px;
}
.security-report-panel-body .agentic-risk-finding-groups {
display: grid;
gap: 42px;
gap: 32px;
}
.security-report-panel-body .agentic-risk-finding-group {
@@ -1501,6 +1556,67 @@ code {
gap: 6px;
}
.security-audit-empty-detail {
margin: 0;
color: var(--ink-soft);
font-size: 0.92rem;
line-height: 1.55;
}
.security-audit-stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1px;
margin: 18px 0 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--line);
}
.security-audit-stat-grid > div {
min-width: 0;
display: grid;
gap: 4px;
padding: 12px;
background: var(--surface);
}
.security-audit-stat-grid dt {
color: var(--ink-soft);
font-size: 0.72rem;
font-weight: 720;
line-height: 1.25;
text-transform: uppercase;
}
.security-audit-stat-grid dd {
margin: 0;
color: var(--ink);
font-size: 1rem;
font-weight: 760;
line-height: 1.2;
}
.security-audit-external-link {
display: inline-flex;
align-items: center;
gap: 6px;
width: fit-content;
margin-top: 18px;
color: var(--ink);
font-size: 0.88rem;
font-weight: 720;
text-decoration: none;
}
.security-audit-external-link:hover,
.security-audit-external-link:focus-visible {
color: var(--ink);
text-decoration: underline;
text-underline-offset: 3px;
}
.vt-advisory-finding {
display: grid;
max-width: 980px;
@@ -1515,16 +1631,16 @@ code {
.static-analysis-findings {
display: grid;
gap: 48px;
gap: 32px;
}
.static-analysis-finding {
display: grid;
gap: 24px;
gap: 18px;
}
.static-analysis-finding:not(:last-child) {
padding-bottom: 48px;
padding-bottom: 32px;
border-bottom: 1px solid var(--line);
}
@@ -1532,15 +1648,11 @@ code {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 18px;
gap: 12px;
}
.static-analysis-finding-header h3 {
margin: 0;
color: var(--ink);
font-family: var(--font-display);
font-size: 1.15rem;
line-height: 1.25;
}
.static-analysis-finding-details {
@@ -1551,9 +1663,10 @@ code {
.static-analysis-finding-details > div {
display: grid;
grid-template-columns: minmax(160px, 320px) minmax(0, 1fr);
gap: 24px;
padding: 22px 0;
align-items: center;
grid-template-columns: minmax(140px, 190px) minmax(0, 1fr);
gap: clamp(18px, 3.5vw, 52px);
padding: 12px 0;
border-bottom: 1px solid var(--line);
}
@@ -1564,8 +1677,8 @@ code {
.static-analysis-finding-details dt {
color: var(--ink);
font-size: 0.9rem;
font-weight: 700;
font-size: 0.86rem;
font-weight: 680;
line-height: 1.35;
}
@@ -1573,24 +1686,10 @@ code {
min-width: 0;
margin: 0;
color: var(--ink-soft);
font-size: 0.95rem;
font-size: 0.88rem;
line-height: 1.55;
}
.static-analysis-finding-details pre {
margin: 0;
max-width: 100%;
overflow-x: auto;
border-radius: 8px;
background: var(--surface-muted);
padding: 12px 14px;
color: var(--ink);
font-size: 0.85rem;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 1100px) {
.security-report-layout {
grid-template-columns: 1fr;
@@ -1619,6 +1718,10 @@ code {
}
@media (max-width: 760px) {
.security-audit-scanner-header {
align-items: center;
}
.security-report-sidebar {
padding: 22px 0;
}
@@ -3191,6 +3294,11 @@ code {
text-decoration: none;
}
.user-badge-link:hover,
.user-badge-link:focus-visible {
text-decoration: none;
}
.user-badge-link:hover .user-name {
text-decoration: underline;
}
@@ -4268,46 +4376,98 @@ code {
justify-content: center;
}
.security-audit-section {
display: grid;
gap: 12px;
}
.security-audit-title-row {
display: flex;
.security-audit-sidebar-label {
display: inline-flex;
align-items: center;
gap: 12px;
gap: 6px;
min-width: 0;
}
.security-audit-title {
font-size: 1.08rem;
.security-audit-sidebar-info {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
}
.security-audit-verdict-badge {
flex: 0 0 auto;
.security-audit-sidebar-value {
display: block;
min-width: 0;
color: var(--ink);
text-decoration: none;
border-radius: var(--r-sm);
}
.security-audit-row {
.security-audit-sidebar-value:hover,
.security-audit-sidebar-value:focus-visible {
color: var(--ink);
text-decoration: none;
outline: none;
}
.security-audit-sidebar-value-row {
display: grid;
align-items: center;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
align-items: stretch;
padding: 18px 0 0;
border-top: 1px solid color-mix(in srgb, var(--line) 72%, transparent);
min-width: 0;
border-radius: var(--r-sm);
padding: 8px 10px;
margin: -2px 0 -6px -10px;
transition:
background-color 0.16s ease,
box-shadow 0.16s ease;
}
.security-audit-signal p {
margin: 0;
color: var(--ink-soft);
font-size: 0.86rem;
line-height: 1.35;
.security-audit-sidebar-value:hover .security-audit-sidebar-value-row,
.security-audit-sidebar-value:focus-visible .security-audit-sidebar-value-row {
background: color-mix(in srgb, var(--surface-muted) 78%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--line) 58%, transparent);
}
.security-audit-suppressed {
margin: 0;
color: var(--ink-soft);
font-size: 0.86rem;
line-height: 1.35;
.security-audit-sidebar-value-row .security-audit-meter {
min-width: 0;
}
.security-audit-sidebar-verdict {
min-width: 0;
color: var(--ink);
font-size: 1rem;
font-weight: 700;
line-height: 1.2;
}
.security-audit-meter {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
height: 6px;
}
.security-audit-meter span {
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--ink-soft) 24%, var(--line));
}
.security-audit-meter[data-level="1"] span:first-child {
background: #79e68e;
}
.security-audit-meter[data-level="2"] span:nth-child(-n + 2) {
background: #6aa9ff;
}
.security-audit-meter[data-level="3"] span:nth-child(-n + 3) {
background: #f1b85a;
}
.security-audit-meter[data-level="4"] span {
background: #ef7777;
}
.publisher-clawscan-note {
@@ -4316,7 +4476,7 @@ code {
min-width: 0;
}
.publisher-clawscan-note-compact {
.publisher-clawscan-note.security-report-panel-compact {
gap: 12px;
}
@@ -4331,35 +4491,6 @@ code {
min-width: 0;
}
.publisher-clawscan-note-info {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
color: var(--ink-soft);
cursor: help;
transition:
border-color 0.16s ease,
background-color 0.16s ease,
color 0.16s ease;
}
.publisher-clawscan-note-info:hover,
.publisher-clawscan-note-info:focus-visible {
border-color: var(--line);
background: var(--surface-muted);
color: var(--ink);
}
.publisher-clawscan-note-tooltip {
max-width: 280px;
line-height: 1.35;
}
.publisher-clawscan-note-body {
display: grid;
gap: 10px;
@@ -4387,7 +4518,7 @@ code {
padding: 0;
border: 0;
background: transparent;
color: var(--accent);
color: var(--ink);
font: inherit;
font-size: 0.86rem;
font-weight: 720;
@@ -4397,102 +4528,11 @@ code {
.publisher-clawscan-note-toggle:hover,
.publisher-clawscan-note-toggle:focus-visible {
color: var(--ink);
text-decoration: underline;
text-underline-offset: 3px;
}
.security-audit-signals {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));
gap: clamp(18px, 2.4vw, 36px);
min-width: 0;
}
.security-audit-signal {
display: grid;
align-content: center;
gap: 10px;
min-width: 0;
padding: 12px;
border-radius: var(--radius-md);
color: inherit;
transition:
background-color 0.16s ease,
box-shadow 0.16s ease,
transform 0.16s ease;
}
.security-audit-signal:hover,
.security-audit-signal:focus-visible {
background: color-mix(in srgb, var(--surface-muted) 76%, transparent);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.16);
outline: none;
transform: translateY(-1px);
}
.security-audit-signal-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
min-width: 0;
}
.security-audit-signal-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 760;
color: var(--ink);
}
.security-audit-signal-status {
flex: 0 0 auto;
font-weight: 760;
color: var(--ink-soft);
}
.security-audit-meter {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 5px;
height: 5px;
}
.security-audit-meter span {
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--line) 68%, transparent);
}
.security-audit-meter[data-level="1"] span:first-child {
background: #79e68e;
}
.security-audit-meter[data-level="2"] span:nth-child(-n + 2) {
background: #6aa9ff;
}
.security-audit-meter[data-level="3"] span:nth-child(-n + 3) {
background: #f1b85a;
}
.security-audit-meter[data-level="4"] span {
background: #ef7777;
}
@media (max-width: 1024px) {
.security-audit-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.security-audit-signals {
grid-template-columns: 1fr;
}
}
.skill-hero-sidebar-meta {
display: grid;
gap: 0;
@@ -4543,7 +4583,7 @@ code {
display: grid;
gap: 8px;
min-width: 0;
padding: 18px 0;
padding: 18px 2px;
border-bottom: 1px solid var(--line);
}
@@ -4596,7 +4636,7 @@ code {
.sidebar-metadata-compact .sidebar-metadata-row {
gap: 7px;
padding: 16px 0;
padding: 16px 2px;
}
.sidebar-metadata-compact .sidebar-metadata-label {
@@ -4984,6 +5024,20 @@ code {
white-space: nowrap;
}
.skill-install-command-wrap .skill-install-prompt-compact {
overflow: visible;
text-overflow: clip;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.skill-install-command-wrap .skill-install-prompt-compact code {
overflow: visible;
text-overflow: clip;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.skill-install-command-inline-button {
position: absolute;
top: 12px;
@@ -5043,12 +5097,16 @@ code {
}
.install-switcher-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: max-content;
border: none;
border-radius: 999px;
background: transparent;
color: var(--ink-soft);
cursor: pointer;
padding: 6px 10px;
padding: 6px 14px;
font-family: var(--font-mono);
font-size: 0.72rem;
font-weight: 700;
@@ -8102,12 +8160,12 @@ code {
}
.scan-findings-title {
margin: 0 0 6px;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink-soft);
margin-bottom: 6px;
}
.scan-finding-row {
@@ -8255,8 +8313,8 @@ a.agentic-risk-finding-title:focus-visible {
.agentic-risk-finding {
display: grid;
gap: 22px;
padding: 36px 0 0;
gap: 18px;
padding: 28px 0 0;
border: 0;
border-radius: 0;
background: transparent;
@@ -8266,6 +8324,13 @@ a.agentic-risk-finding-title:focus-visible {
padding-top: 0;
}
.agentic-risk-finding-header {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 12px;
}
.agentic-risk-finding-badges {
display: inline-flex;
flex: 0 0 auto;
@@ -8291,7 +8356,7 @@ a.agentic-risk-finding-title:focus-visible {
display: grid;
grid-template-columns: minmax(140px, 190px) minmax(0, 1fr);
gap: clamp(18px, 3.5vw, 52px);
padding: 16px 0;
padding: 12px 0;
border-top: 1px solid var(--line);
}
@@ -8301,7 +8366,7 @@ a.agentic-risk-finding-title:focus-visible {
}
.agentic-risk-report-row-secondary {
padding-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--line);
}