mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
feat: consolidate security audit UI (#2349)
This commit is contained in:
@@ -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"
|
||||
|
||||
Vendored
+2
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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) },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user