mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
feat: add security scan admin tooling
This commit is contained in:
Vendored
+6
@@ -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_securityV1 from "../httpApiV1/securityV1.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";
|
||||
@@ -86,6 +87,7 @@ import type * as lib_reservedHandles from "../lib/reservedHandles.js";
|
||||
import type * as lib_reservedSlugs from "../lib/reservedSlugs.js";
|
||||
import type * as lib_searchText from "../lib/searchText.js";
|
||||
import type * as lib_securityPrompt from "../lib/securityPrompt.js";
|
||||
import type * as lib_securityScanRollups from "../lib/securityScanRollups.js";
|
||||
import type * as lib_skillBackfill from "../lib/skillBackfill.js";
|
||||
import type * as lib_skillCapabilityTags from "../lib/skillCapabilityTags.js";
|
||||
import type * as lib_skillIcon from "../lib/skillIcon.js";
|
||||
@@ -115,6 +117,7 @@ import type * as search from "../search.js";
|
||||
import type * as securityDataset from "../securityDataset.js";
|
||||
import type * as securityDatasetNode from "../securityDatasetNode.js";
|
||||
import type * as securityScan from "../securityScan.js";
|
||||
import type * as securityScans from "../securityScans.js";
|
||||
import type * as seed from "../seed.js";
|
||||
import type * as seedSouls from "../seedSouls.js";
|
||||
import type * as skillStatEvents from "../skillStatEvents.js";
|
||||
@@ -164,6 +167,7 @@ declare const fullApi: ApiFromModules<{
|
||||
httpApiV1: typeof httpApiV1;
|
||||
"httpApiV1/docsSessionV1": typeof httpApiV1_docsSessionV1;
|
||||
"httpApiV1/packagesV1": typeof httpApiV1_packagesV1;
|
||||
"httpApiV1/securityV1": typeof httpApiV1_securityV1;
|
||||
"httpApiV1/shared": typeof httpApiV1_shared;
|
||||
"httpApiV1/skillsV1": typeof httpApiV1_skillsV1;
|
||||
"httpApiV1/soulsV1": typeof httpApiV1_soulsV1;
|
||||
@@ -218,6 +222,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/reservedSlugs": typeof lib_reservedSlugs;
|
||||
"lib/searchText": typeof lib_searchText;
|
||||
"lib/securityPrompt": typeof lib_securityPrompt;
|
||||
"lib/securityScanRollups": typeof lib_securityScanRollups;
|
||||
"lib/skillBackfill": typeof lib_skillBackfill;
|
||||
"lib/skillCapabilityTags": typeof lib_skillCapabilityTags;
|
||||
"lib/skillIcon": typeof lib_skillIcon;
|
||||
@@ -247,6 +252,7 @@ declare const fullApi: ApiFromModules<{
|
||||
securityDataset: typeof securityDataset;
|
||||
securityDatasetNode: typeof securityDatasetNode;
|
||||
securityScan: typeof securityScan;
|
||||
securityScans: typeof securityScans;
|
||||
seed: typeof seed;
|
||||
seedSouls: typeof seedSouls;
|
||||
skillStatEvents: typeof skillStatEvents;
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
publishSoulV1Http,
|
||||
resolveSkillVersionV1Http,
|
||||
searchSkillsV1Http,
|
||||
securityGetRouterV1Http,
|
||||
securityPostRouterV1Http,
|
||||
skillsDeleteRouterV1Http,
|
||||
skillsGetRouterV1Http,
|
||||
skillsPostRouterV1Http,
|
||||
@@ -113,6 +115,12 @@ http.route({
|
||||
handler: packagesGetRouterV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
pathPrefix: `${ApiRoutes.security}/`,
|
||||
method: "GET",
|
||||
handler: securityGetRouterV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
pathPrefix: "/api/npm/",
|
||||
method: "GET",
|
||||
@@ -149,6 +157,12 @@ http.route({
|
||||
handler: packagesPostRouterV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
pathPrefix: `${ApiRoutes.security}/`,
|
||||
method: "POST",
|
||||
handler: securityPostRouterV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
pathPrefix: `${ApiRoutes.packages}/`,
|
||||
method: "DELETE",
|
||||
|
||||
@@ -225,6 +225,175 @@ describe("httpApiV1 handlers", () => {
|
||||
expect(runAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("security summary requires staff token and returns rollups", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:moderator",
|
||||
user: { _id: "users:moderator", role: "moderator" },
|
||||
} as never);
|
||||
const runQuery = vi.fn(async (_ref, args) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
return {
|
||||
generatedAt: 123,
|
||||
updatedAt: 122,
|
||||
stale: false,
|
||||
totals: {
|
||||
skills: { benign: 1, suspicious: 2, malicious: 3, pending: 4, unknown: 5 },
|
||||
plugins: { benign: 6, suspicious: 7, malicious: 8, pending: 9, unknown: 10 },
|
||||
},
|
||||
};
|
||||
});
|
||||
const response = await __handlers.securityGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation: vi.fn().mockResolvedValue(okRate()) }),
|
||||
new Request("https://example.com/api/v1/security/summary", {
|
||||
headers: { authorization: "Bearer token" },
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.status !== 200) throw new Error(await response.text());
|
||||
expect(await response.json()).toEqual({
|
||||
generatedAt: 123,
|
||||
updatedAt: 122,
|
||||
stale: false,
|
||||
totals: {
|
||||
skills: { benign: 1, suspicious: 2, malicious: 3, pending: 4, unknown: 5 },
|
||||
plugins: { benign: 6, suspicious: 7, malicious: 8, pending: 9, unknown: 10 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("security skill rescan posts to staff mutation", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:moderator",
|
||||
user: { _id: "users:moderator", role: "moderator" },
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_ref, args) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
expect(args).toEqual({ actorUserId: "users:moderator", slug: "demo" });
|
||||
return {
|
||||
ok: true,
|
||||
state: "queued",
|
||||
entityType: "skill",
|
||||
target: "demo",
|
||||
version: "1.0.0",
|
||||
scheduledScanners: ["static", "clawscan", "virustotal"],
|
||||
};
|
||||
});
|
||||
const response = await __handlers.securityPostRouterV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://example.com/api/v1/security/skills/demo/rescan", {
|
||||
method: "POST",
|
||||
headers: { authorization: "Bearer token" },
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.status !== 200) throw new Error(await response.text());
|
||||
expect(await response.json()).toMatchObject({
|
||||
ok: true,
|
||||
state: "queued",
|
||||
entityType: "skill",
|
||||
target: "demo",
|
||||
});
|
||||
});
|
||||
|
||||
it("security summary returns forbidden for non-staff tokens", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:regular",
|
||||
user: { _id: "users:regular", role: "user" },
|
||||
} as never);
|
||||
const runQuery = vi.fn(async (_ref, args) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
throw new Error("Forbidden");
|
||||
});
|
||||
|
||||
const response = await __handlers.securityGetRouterV1Handler(
|
||||
makeCtx({ runQuery }),
|
||||
new Request("https://example.com/api/v1/security/summary", {
|
||||
headers: { authorization: "Bearer token" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.text()).toBe("Forbidden");
|
||||
});
|
||||
|
||||
it("security plugin rescan rejects malformed json", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:moderator",
|
||||
user: { _id: "users:moderator", role: "moderator" },
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_ref, args) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
throw new Error("Security rescan should not run");
|
||||
});
|
||||
|
||||
const response = await __handlers.securityPostRouterV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://example.com/api/v1/security/plugins/demo/rescan", {
|
||||
method: "POST",
|
||||
headers: { authorization: "Bearer token", "content-type": "application/json" },
|
||||
body: "{",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.text()).toBe("Invalid JSON");
|
||||
expect(runMutation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("security rescan returns typed target-not-found results", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:moderator",
|
||||
user: { _id: "users:moderator", role: "moderator" },
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_ref, args) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
return {
|
||||
ok: false,
|
||||
state: "target_not_found",
|
||||
entityType: "skill",
|
||||
target: "missing",
|
||||
scheduledScanners: [],
|
||||
};
|
||||
});
|
||||
|
||||
const response = await __handlers.securityPostRouterV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://example.com/api/v1/security/skills/missing/rescan", {
|
||||
method: "POST",
|
||||
headers: { authorization: "Bearer token" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({
|
||||
ok: false,
|
||||
state: "target_not_found",
|
||||
target: "missing",
|
||||
});
|
||||
});
|
||||
|
||||
it("security rescan returns forbidden for non-staff tokens", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:regular",
|
||||
user: { _id: "users:regular", role: "user" },
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_ref, args) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
throw new Error("Forbidden");
|
||||
});
|
||||
|
||||
const response = await __handlers.securityPostRouterV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://example.com/api/v1/security/skills/demo/rescan", {
|
||||
method: "POST",
|
||||
headers: { authorization: "Bearer token" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.text()).toBe("Forbidden");
|
||||
});
|
||||
|
||||
it("users/restore calls restore action for admin", async () => {
|
||||
const runAction = vi.fn().mockResolvedValue({ ok: true, totalRestored: 1, results: [] });
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
pluginsGetRouterV1Handler,
|
||||
publishPackageV1Handler,
|
||||
} from "./httpApiV1/packagesV1";
|
||||
import { securityGetRouterV1Handler, securityPostRouterV1Handler } from "./httpApiV1/securityV1";
|
||||
import {
|
||||
listSkillsV1Handler,
|
||||
publishSkillV1Handler,
|
||||
@@ -46,6 +47,8 @@ export const npmMirrorGetHttp = httpAction(npmMirrorGetHandler);
|
||||
export const listCodePluginsV1Http = httpAction(listCodePluginsV1Handler);
|
||||
export const listBundlePluginsV1Http = httpAction(listBundlePluginsV1Handler);
|
||||
export const verifyDocsSessionV1Http = httpAction(verifyDocsSessionV1Handler);
|
||||
export const securityGetRouterV1Http = httpAction(securityGetRouterV1Handler);
|
||||
export const securityPostRouterV1Http = httpAction(securityPostRouterV1Handler);
|
||||
|
||||
export const searchSkillsV1Http = httpAction(searchSkillsV1Handler);
|
||||
export const resolveSkillVersionV1Http = httpAction(resolveSkillVersionV1Handler);
|
||||
@@ -82,6 +85,8 @@ export const __handlers = {
|
||||
listCodePluginsV1Handler,
|
||||
listBundlePluginsV1Handler,
|
||||
verifyDocsSessionV1Handler,
|
||||
securityGetRouterV1Handler,
|
||||
securityPostRouterV1Handler,
|
||||
searchSkillsV1Handler,
|
||||
resolveSkillVersionV1Handler,
|
||||
listSkillsV1Handler,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
ApiV1SecurityRescanResponseSchema,
|
||||
ApiV1SecurityScanSummaryResponseSchema,
|
||||
parseArk,
|
||||
} from "clawhub-schema";
|
||||
import { internal } from "../_generated/api";
|
||||
import type { ActionCtx } from "../_generated/server";
|
||||
import { applyRateLimit } from "../lib/httpRateLimit";
|
||||
import {
|
||||
formatAuthzMessage,
|
||||
getPathSegments,
|
||||
json,
|
||||
requireApiTokenUserOrResponse,
|
||||
text,
|
||||
} from "./shared";
|
||||
|
||||
const internalRefs = internal as unknown as {
|
||||
securityScans: {
|
||||
getSecurityScanSummaryForStaffInternal: unknown;
|
||||
requestSkillSecurityRescanForStaffInternal: unknown;
|
||||
requestPluginSecurityRescanForStaffInternal: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
async function runQueryRef<T>(ctx: ActionCtx, ref: unknown, args: unknown): Promise<T> {
|
||||
return (await ctx.runQuery(ref as never, args as never)) as T;
|
||||
}
|
||||
|
||||
async function runMutationRef<T>(ctx: ActionCtx, ref: unknown, args: unknown): Promise<T> {
|
||||
return (await ctx.runMutation(ref as never, args as never)) as T;
|
||||
}
|
||||
|
||||
function securityErrorToResponse(
|
||||
error: unknown,
|
||||
fallback: "Security summary failed" | "Security rescan failed",
|
||||
headers: HeadersInit,
|
||||
) {
|
||||
const message = error instanceof Error ? error.message : fallback;
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes("unauthorized")) {
|
||||
return text(formatAuthzMessage(error, "Unauthorized"), 401, headers);
|
||||
}
|
||||
if (lower.includes("forbidden")) {
|
||||
return text(formatAuthzMessage(error, "Forbidden"), 403, headers);
|
||||
}
|
||||
return text(message, 400, headers);
|
||||
}
|
||||
|
||||
async function parseOptionalJsonObject(request: Request, headers: HeadersInit) {
|
||||
const raw = await request.text();
|
||||
if (!raw.trim()) return { ok: true as const, payload: {} as Record<string, unknown> };
|
||||
try {
|
||||
const payload = JSON.parse(raw) as unknown;
|
||||
return {
|
||||
ok: true as const,
|
||||
payload:
|
||||
payload && typeof payload === "object" && !Array.isArray(payload)
|
||||
? (payload as Record<string, unknown>)
|
||||
: {},
|
||||
};
|
||||
} catch {
|
||||
return { ok: false as const, response: text("Invalid JSON", 400, headers) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function securityGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const segments = getPathSegments(request, "/api/v1/security/");
|
||||
if (segments[0] !== "summary" || segments.length !== 1) return text("Not found", 404);
|
||||
const rate = await applyRateLimit(ctx, request, "read");
|
||||
if (!rate.ok) return rate.response;
|
||||
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
|
||||
if (!auth.ok) return auth.response;
|
||||
try {
|
||||
const result = await runQueryRef(
|
||||
ctx,
|
||||
internalRefs.securityScans.getSecurityScanSummaryForStaffInternal,
|
||||
{ actorUserId: auth.userId },
|
||||
);
|
||||
const parsed = parseArk(
|
||||
ApiV1SecurityScanSummaryResponseSchema,
|
||||
result,
|
||||
"Security scan summary response",
|
||||
);
|
||||
return json(parsed, 200, rate.headers);
|
||||
} catch (error) {
|
||||
return securityErrorToResponse(error, "Security summary failed", rate.headers);
|
||||
}
|
||||
}
|
||||
|
||||
export async function securityPostRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const segments = getPathSegments(request, "/api/v1/security/");
|
||||
const rate = await applyRateLimit(ctx, request, "write");
|
||||
if (!rate.ok) return rate.response;
|
||||
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
|
||||
if (!auth.ok) return auth.response;
|
||||
try {
|
||||
let result: unknown;
|
||||
if (
|
||||
segments[0] === "skills" &&
|
||||
segments[1] &&
|
||||
segments[2] === "rescan" &&
|
||||
segments.length === 3
|
||||
) {
|
||||
result = await runMutationRef(
|
||||
ctx,
|
||||
internalRefs.securityScans.requestSkillSecurityRescanForStaffInternal,
|
||||
{ actorUserId: auth.userId, slug: segments[1] },
|
||||
);
|
||||
} else if (
|
||||
segments[0] === "plugins" &&
|
||||
segments[1] &&
|
||||
segments[2] === "rescan" &&
|
||||
segments.length === 3
|
||||
) {
|
||||
const parsedBody = await parseOptionalJsonObject(request, rate.headers);
|
||||
if (!parsedBody.ok) return parsedBody.response;
|
||||
const body = parsedBody.payload;
|
||||
const version = typeof body.version === "string" ? body.version : undefined;
|
||||
result = await runMutationRef(
|
||||
ctx,
|
||||
internalRefs.securityScans.requestPluginSecurityRescanForStaffInternal,
|
||||
{ actorUserId: auth.userId, name: segments[1], version },
|
||||
);
|
||||
} else {
|
||||
return text("Not found", 404, rate.headers);
|
||||
}
|
||||
const parsed = parseArk(ApiV1SecurityRescanResponseSchema, result, "Security rescan response");
|
||||
return json(parsed, 200, rate.headers);
|
||||
} catch (error) {
|
||||
return securityErrorToResponse(error, "Security rescan failed", rate.headers);
|
||||
}
|
||||
}
|
||||
@@ -257,8 +257,13 @@ export async function soulsGetRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
const normalized = path.trim();
|
||||
const normalizedLower = normalized.toLowerCase();
|
||||
const file =
|
||||
version.files.find((entry) => entry.path === normalized) ??
|
||||
version.files.find((entry) => entry.path.toLowerCase() === normalizedLower);
|
||||
version.files.find(
|
||||
(entry: Doc<"soulVersions">["files"][number]) => entry.path === normalized,
|
||||
) ??
|
||||
version.files.find(
|
||||
(entry: Doc<"soulVersions">["files"][number]) =>
|
||||
entry.path.toLowerCase() === normalizedLower,
|
||||
);
|
||||
if (!file) return text("File not found", 404, rate.headers);
|
||||
if (file.size > MAX_RAW_FILE_BYTES) return text("File exceeds 200KB limit", 413, rate.headers);
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getSecurityScanRollupDeltas,
|
||||
securityScanStatusFromPackage,
|
||||
securityScanStatusFromSkill,
|
||||
} from "./securityScanRollups";
|
||||
|
||||
describe("security scan rollup helpers", () => {
|
||||
it("maps skill moderation state to staff scan buckets", () => {
|
||||
expect(
|
||||
securityScanStatusFromSkill({ softDeletedAt: undefined, moderationVerdict: "clean" }),
|
||||
).toBe("benign");
|
||||
expect(
|
||||
securityScanStatusFromSkill({ softDeletedAt: undefined, moderationVerdict: "suspicious" }),
|
||||
).toBe("suspicious");
|
||||
expect(
|
||||
securityScanStatusFromSkill({ softDeletedAt: undefined, moderationVerdict: "malicious" }),
|
||||
).toBe("malicious");
|
||||
expect(
|
||||
securityScanStatusFromSkill({
|
||||
softDeletedAt: undefined,
|
||||
moderationVerdict: undefined,
|
||||
isSuspicious: true,
|
||||
}),
|
||||
).toBe("suspicious");
|
||||
expect(
|
||||
securityScanStatusFromSkill({
|
||||
softDeletedAt: undefined,
|
||||
moderationVerdict: "clean",
|
||||
moderationReason: "pending.scan.stale",
|
||||
}),
|
||||
).toBe("pending");
|
||||
expect(securityScanStatusFromSkill({ softDeletedAt: undefined })).toBe("unknown");
|
||||
expect(
|
||||
securityScanStatusFromSkill({ softDeletedAt: Date.now(), moderationVerdict: "clean" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("maps plugin scan state to staff scan buckets", () => {
|
||||
expect(securityScanStatusFromPackage({ softDeletedAt: undefined, scanStatus: "clean" })).toBe(
|
||||
"benign",
|
||||
);
|
||||
expect(
|
||||
securityScanStatusFromPackage({ softDeletedAt: undefined, scanStatus: "suspicious" }),
|
||||
).toBe("suspicious");
|
||||
expect(
|
||||
securityScanStatusFromPackage({ softDeletedAt: undefined, scanStatus: "malicious" }),
|
||||
).toBe("malicious");
|
||||
expect(securityScanStatusFromPackage({ softDeletedAt: undefined, scanStatus: "pending" })).toBe(
|
||||
"pending",
|
||||
);
|
||||
expect(securityScanStatusFromPackage({ softDeletedAt: undefined, scanStatus: "not-run" })).toBe(
|
||||
"unknown",
|
||||
);
|
||||
expect(
|
||||
securityScanStatusFromPackage({ softDeletedAt: Date.now(), scanStatus: "clean" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("emits one decrement and one increment for status transitions", () => {
|
||||
expect(getSecurityScanRollupDeltas("suspicious", "benign")).toEqual([
|
||||
{ status: "suspicious", delta: -1 },
|
||||
{ status: "benign", delta: 1 },
|
||||
]);
|
||||
expect(getSecurityScanRollupDeltas("benign", "benign")).toEqual([]);
|
||||
expect(getSecurityScanRollupDeltas(null, "malicious")).toEqual([
|
||||
{ status: "malicious", delta: 1 },
|
||||
]);
|
||||
expect(getSecurityScanRollupDeltas("pending", null)).toEqual([
|
||||
{ status: "pending", delta: -1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { Id } from "../_generated/dataModel";
|
||||
import type { MutationCtx } from "../_generated/server";
|
||||
|
||||
export const SECURITY_SCAN_STATUSES = [
|
||||
"benign",
|
||||
"suspicious",
|
||||
"malicious",
|
||||
"pending",
|
||||
"unknown",
|
||||
] as const;
|
||||
|
||||
export const SECURITY_SCAN_ENTITY_TYPES = ["skill", "plugin"] as const;
|
||||
|
||||
export type SecurityScanStatus = (typeof SECURITY_SCAN_STATUSES)[number];
|
||||
export type SecurityScanEntityType = (typeof SECURITY_SCAN_ENTITY_TYPES)[number];
|
||||
|
||||
type SkillScanLike = {
|
||||
_id?: Id<"skills">;
|
||||
softDeletedAt?: number;
|
||||
moderationVerdict?: "clean" | "suspicious" | "malicious";
|
||||
moderationReason?: string;
|
||||
isSuspicious?: boolean;
|
||||
};
|
||||
|
||||
type PackageScanLike = {
|
||||
_id?: Id<"packages">;
|
||||
softDeletedAt?: number;
|
||||
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run";
|
||||
};
|
||||
|
||||
export function emptySecurityScanCounts(): Record<SecurityScanStatus, number> {
|
||||
return {
|
||||
benign: 0,
|
||||
suspicious: 0,
|
||||
malicious: 0,
|
||||
pending: 0,
|
||||
unknown: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function securityScanStatusFromSkill(skill: SkillScanLike): SecurityScanStatus | null {
|
||||
if (skill.softDeletedAt !== undefined) return null;
|
||||
const reason = skill.moderationReason?.trim().toLowerCase();
|
||||
if (
|
||||
reason === "pending.scan" ||
|
||||
reason === "pending.scan.stale" ||
|
||||
reason?.endsWith(".pending")
|
||||
) {
|
||||
return "pending";
|
||||
}
|
||||
if (skill.moderationVerdict === "clean") return "benign";
|
||||
if (skill.moderationVerdict === "suspicious") return "suspicious";
|
||||
if (skill.moderationVerdict === "malicious") return "malicious";
|
||||
if (skill.isSuspicious) return "suspicious";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function securityScanStatusFromPackage(pkg: PackageScanLike): SecurityScanStatus | null {
|
||||
if (pkg.softDeletedAt !== undefined) return null;
|
||||
if (pkg.scanStatus === "clean") return "benign";
|
||||
if (pkg.scanStatus === "suspicious") return "suspicious";
|
||||
if (pkg.scanStatus === "malicious") return "malicious";
|
||||
if (pkg.scanStatus === "pending") return "pending";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function getSecurityScanRollupDeltas(
|
||||
previousStatus: SecurityScanStatus | null | undefined,
|
||||
nextStatus: SecurityScanStatus | null | undefined,
|
||||
) {
|
||||
if (previousStatus === nextStatus) return [];
|
||||
return [
|
||||
...(previousStatus ? [{ status: previousStatus, delta: -1 }] : []),
|
||||
...(nextStatus ? [{ status: nextStatus, delta: 1 }] : []),
|
||||
];
|
||||
}
|
||||
|
||||
export async function syncSecurityScanEntityState(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
params: {
|
||||
entityType: SecurityScanEntityType;
|
||||
targetId: string;
|
||||
label: string;
|
||||
status: SecurityScanStatus | null;
|
||||
now?: number;
|
||||
},
|
||||
) {
|
||||
const now = params.now ?? Date.now();
|
||||
let existing;
|
||||
try {
|
||||
existing = await ctx.db
|
||||
.query("securityScanEntityStates")
|
||||
.withIndex("by_entity_target", (q) =>
|
||||
q.eq("entityType", params.entityType).eq("targetId", params.targetId),
|
||||
)
|
||||
.unique();
|
||||
} catch (error) {
|
||||
if (isUnsupportedTestHarnessTableError(error)) {
|
||||
return { ok: true as const, skipped: "unsupported_test_harness" as const };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const previousStatus = existing?.status ?? null;
|
||||
const deltas = getSecurityScanRollupDeltas(previousStatus, params.status);
|
||||
|
||||
if (params.status) {
|
||||
const doc = {
|
||||
entityType: params.entityType,
|
||||
targetId: params.targetId,
|
||||
label: params.label,
|
||||
status: params.status,
|
||||
updatedAt: now,
|
||||
};
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, doc);
|
||||
} else {
|
||||
await ctx.db.insert("securityScanEntityStates", doc);
|
||||
}
|
||||
} else if (existing) {
|
||||
await ctx.db.delete(existing._id);
|
||||
}
|
||||
|
||||
for (const { status, delta } of deltas) {
|
||||
const rollup = await ctx.db
|
||||
.query("securityScanRollups")
|
||||
.withIndex("by_entity_status", (q) =>
|
||||
q.eq("entityType", params.entityType).eq("status", status),
|
||||
)
|
||||
.unique();
|
||||
const nextCount = Math.max(0, (rollup?.count ?? 0) + delta);
|
||||
if (rollup) {
|
||||
await ctx.db.patch(rollup._id, { count: nextCount, updatedAt: now });
|
||||
} else {
|
||||
await ctx.db.insert("securityScanRollups", {
|
||||
entityType: params.entityType,
|
||||
status,
|
||||
count: nextCount,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
function isUnsupportedTestHarnessTableError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
message.includes("Unexpected table securityScan") ||
|
||||
message.includes("Unexpected table: securityScan") ||
|
||||
message.includes("unexpected table securityScan") ||
|
||||
message.includes("unexpected table: securityScan") ||
|
||||
message.includes("Unexpected query table: securityScan") ||
|
||||
message.includes("Cannot read properties of undefined")
|
||||
);
|
||||
}
|
||||
+59
-2
@@ -81,6 +81,10 @@ import {
|
||||
} from "./lib/publishLimits";
|
||||
import { MAX_ACTIVE_REPORTS_PER_USER, MAX_REPORT_REASON_LENGTH } from "./lib/reporting";
|
||||
import { matchesAllTokens, matchesExploratoryTokenPrefixes, tokenize } from "./lib/searchText";
|
||||
import {
|
||||
securityScanStatusFromPackage,
|
||||
syncSecurityScanEntityState,
|
||||
} from "./lib/securityScanRollups";
|
||||
import { hashSkillFiles } from "./lib/skills";
|
||||
import { runStaticPublishScan } from "./lib/staticPublishScan";
|
||||
|
||||
@@ -2600,6 +2604,12 @@ async function softDeletePackageDoc(
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "plugin",
|
||||
targetId: String(pkg._id),
|
||||
label: pkg.name,
|
||||
status: null,
|
||||
});
|
||||
return {
|
||||
ok: true as const,
|
||||
packageId: pkg._id,
|
||||
@@ -2639,6 +2649,12 @@ async function softDeletePackageDoc(
|
||||
ownerHandle: deleteOwner?.handle ?? "",
|
||||
ownerKind: deleteOwner?.kind,
|
||||
});
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "plugin",
|
||||
targetId: String(pkg._id),
|
||||
label: pkg.name,
|
||||
status: securityScanStatusFromPackage(nextPackage),
|
||||
});
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: params.actorUserId,
|
||||
action: "package.delete",
|
||||
@@ -2729,6 +2745,12 @@ async function restorePackageDoc(
|
||||
},
|
||||
) {
|
||||
if (!pkg.softDeletedAt) {
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "plugin",
|
||||
targetId: String(pkg._id),
|
||||
label: pkg.name,
|
||||
status: pkg.family === "skill" ? null : securityScanStatusFromPackage(pkg),
|
||||
});
|
||||
return {
|
||||
ok: true as const,
|
||||
packageId: pkg._id,
|
||||
@@ -2817,6 +2839,12 @@ async function restorePackageDoc(
|
||||
ownerHandle: restoreOwner?.handle ?? "",
|
||||
ownerKind: restoreOwner?.kind,
|
||||
});
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "plugin",
|
||||
targetId: String(pkg._id),
|
||||
label: pkg.name,
|
||||
status: nextPackage.family === "skill" ? null : securityScanStatusFromPackage(nextPackage),
|
||||
});
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: params.actorUserId,
|
||||
action: "package.undelete",
|
||||
@@ -5371,7 +5399,7 @@ export const insertReleaseInternal = internalMutation({
|
||||
await ctx.db.patch(priorRelease._id, { distTags: nextDistTags });
|
||||
}
|
||||
|
||||
await ctx.db.patch(pkgId, {
|
||||
const packagePatch: Partial<Doc<"packages">> = {
|
||||
displayName: args.displayName,
|
||||
ownerUserId: args.ownerUserId,
|
||||
ownerPublisherId: args.ownerPublisherId ?? pkg.ownerPublisherId,
|
||||
@@ -5406,6 +5434,14 @@ export const insertReleaseInternal = internalMutation({
|
||||
scanStatus: shouldPromoteLatest ? args.verification?.scanStatus : pkg.scanStatus,
|
||||
stats: { ...pkg.stats, versions: (pkg.stats?.versions ?? 0) + 1 },
|
||||
updatedAt: now,
|
||||
};
|
||||
await ctx.db.patch(pkgId, packagePatch);
|
||||
const nextPackage = { ...pkg, ...packagePatch } as Doc<"packages">;
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "plugin",
|
||||
targetId: String(pkgId),
|
||||
label: nextPackage.name,
|
||||
status: nextPackage.family === "skill" ? null : securityScanStatusFromPackage(nextPackage),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -5448,6 +5484,18 @@ async function syncLatestPackageVerification(ctx: MutationCtx, release: Doc<"pac
|
||||
}
|
||||
: pkg.latestVersionSummary,
|
||||
});
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "plugin",
|
||||
targetId: String(pkg._id),
|
||||
label: pkg.name,
|
||||
status:
|
||||
pkg.family === "skill"
|
||||
? null
|
||||
: securityScanStatusFromPackage({
|
||||
softDeletedAt: pkg.softDeletedAt,
|
||||
scanStatus,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export const updateReleaseScanResultsInternal = internalMutation({
|
||||
@@ -5686,10 +5734,19 @@ export const backfillLatestPackageScanStatusInternal = internalMutation({
|
||||
pkg.latestVersionSummary?.verification?.scanStatus !==
|
||||
nextLatestVersionSummary?.verification?.scanStatus
|
||||
) {
|
||||
await ctx.db.patch(pkg._id, {
|
||||
const packagePatch: Partial<Doc<"packages">> = {
|
||||
verification: nextVerification,
|
||||
scanStatus,
|
||||
latestVersionSummary: nextLatestVersionSummary,
|
||||
};
|
||||
const nextPackage = { ...pkg, ...packagePatch } as Doc<"packages">;
|
||||
await ctx.db.patch(pkg._id, packagePatch);
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "plugin",
|
||||
targetId: String(pkg._id),
|
||||
label: pkg.name,
|
||||
status:
|
||||
nextPackage.family === "skill" ? null : securityScanStatusFromPackage(nextPackage),
|
||||
});
|
||||
patched++;
|
||||
}
|
||||
|
||||
@@ -1726,6 +1726,53 @@ const vtScanLogs = defineTable({
|
||||
createdAt: v.number(),
|
||||
}).index("by_type_date", ["type", "createdAt"]);
|
||||
|
||||
const securityScanStatusValidator = v.union(
|
||||
v.literal("benign"),
|
||||
v.literal("suspicious"),
|
||||
v.literal("malicious"),
|
||||
v.literal("pending"),
|
||||
v.literal("unknown"),
|
||||
);
|
||||
|
||||
const securityScanEntityTypeValidator = v.union(v.literal("skill"), v.literal("plugin"));
|
||||
|
||||
const securityScanRollups = defineTable({
|
||||
entityType: securityScanEntityTypeValidator,
|
||||
status: securityScanStatusValidator,
|
||||
count: v.number(),
|
||||
updatedAt: v.number(),
|
||||
}).index("by_entity_status", ["entityType", "status"]);
|
||||
|
||||
const securityScanEntityStates = defineTable({
|
||||
entityType: securityScanEntityTypeValidator,
|
||||
targetId: v.string(),
|
||||
label: v.string(),
|
||||
status: securityScanStatusValidator,
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_entity_target", ["entityType", "targetId"])
|
||||
.index("by_entity_status_updated", ["entityType", "status", "updatedAt"]);
|
||||
|
||||
const securityScanRequests = defineTable({
|
||||
entityType: securityScanEntityTypeValidator,
|
||||
targetId: v.string(),
|
||||
targetLabel: v.string(),
|
||||
version: v.optional(v.string()),
|
||||
status: v.union(v.literal("queued")),
|
||||
scanners: v.array(v.string()),
|
||||
requestedByUserId: v.id("users"),
|
||||
createdAt: v.number(),
|
||||
expiresAt: v.number(),
|
||||
})
|
||||
.index("by_entity_target_status_expires", ["entityType", "targetId", "status", "expiresAt"])
|
||||
.index("by_requester_created", ["requestedByUserId", "createdAt"]);
|
||||
|
||||
const securityScanRollupMetadata = defineTable({
|
||||
key: v.string(),
|
||||
completedAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
}).index("by_key", ["key"]);
|
||||
|
||||
const apiTokens = defineTable({
|
||||
userId: v.id("users"),
|
||||
label: v.string(),
|
||||
@@ -1934,6 +1981,10 @@ export default defineSchema({
|
||||
soulStars,
|
||||
auditLogs,
|
||||
vtScanLogs,
|
||||
securityScanRollups,
|
||||
securityScanEntityStates,
|
||||
securityScanRequests,
|
||||
securityScanRollupMetadata,
|
||||
apiTokens,
|
||||
cliDeviceCodes,
|
||||
rateLimits,
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
import { paginationOptsValidator } from "convex/server";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import {
|
||||
action,
|
||||
internalAction,
|
||||
internalMutation,
|
||||
internalQuery,
|
||||
mutation,
|
||||
query,
|
||||
} from "./functions";
|
||||
import { assertModerator, requireUser, requireUserFromAction } from "./lib/access";
|
||||
import { normalizePackageName } from "./lib/packageRegistry";
|
||||
import {
|
||||
emptySecurityScanCounts,
|
||||
securityScanStatusFromPackage,
|
||||
securityScanStatusFromSkill,
|
||||
syncSecurityScanEntityState,
|
||||
type SecurityScanEntityType,
|
||||
type SecurityScanStatus,
|
||||
} from "./lib/securityScanRollups";
|
||||
|
||||
const RESCAN_REQUEST_TTL_MS = 10 * 60 * 1000;
|
||||
const SECURITY_SCAN_ROLLUP_REBUILD_KEY = "security-scan-rollups-v1";
|
||||
|
||||
const securityScanStatusValidator = v.union(
|
||||
v.literal("benign"),
|
||||
v.literal("suspicious"),
|
||||
v.literal("malicious"),
|
||||
v.literal("pending"),
|
||||
v.literal("unknown"),
|
||||
);
|
||||
|
||||
const securityScanEntityTypeValidator = v.union(v.literal("skill"), v.literal("plugin"));
|
||||
|
||||
type SecurityScanCounts = Record<SecurityScanStatus, number>;
|
||||
|
||||
function buildEmptyTotals(): Record<SecurityScanEntityType, SecurityScanCounts> {
|
||||
return {
|
||||
skill: emptySecurityScanCounts(),
|
||||
plugin: emptySecurityScanCounts(),
|
||||
};
|
||||
}
|
||||
|
||||
async function requireModeratorById(ctx: Pick<QueryCtx, "db">, actorUserId: Id<"users">) {
|
||||
const actor = await ctx.db.get(actorUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
||||
assertModerator(actor);
|
||||
return actor;
|
||||
}
|
||||
|
||||
async function readSecurityScanSummary(ctx: Pick<QueryCtx, "db">) {
|
||||
const totals = buildEmptyTotals();
|
||||
const rollups = await ctx.db.query("securityScanRollups").collect();
|
||||
const rebuildMetadata = await ctx.db
|
||||
.query("securityScanRollupMetadata")
|
||||
.withIndex("by_key", (q) => q.eq("key", SECURITY_SCAN_ROLLUP_REBUILD_KEY))
|
||||
.unique();
|
||||
let updatedAt = 0;
|
||||
for (const rollup of rollups) {
|
||||
totals[rollup.entityType][rollup.status] = rollup.count;
|
||||
updatedAt = Math.max(updatedAt, rollup.updatedAt);
|
||||
}
|
||||
return {
|
||||
generatedAt: Date.now(),
|
||||
totals: {
|
||||
skills: totals.skill,
|
||||
plugins: totals.plugin,
|
||||
},
|
||||
stale: !rebuildMetadata,
|
||||
updatedAt: updatedAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function findSkillBySlug(ctx: Pick<QueryCtx, "db">, slug: string) {
|
||||
return await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", slug.trim().toLowerCase()))
|
||||
.unique();
|
||||
}
|
||||
|
||||
async function findPackageByName(ctx: Pick<QueryCtx, "db">, name: string) {
|
||||
const normalizedName = normalizePackageName(name);
|
||||
return await ctx.db
|
||||
.query("packages")
|
||||
.withIndex("by_name", (q) => q.eq("normalizedName", normalizedName))
|
||||
.unique();
|
||||
}
|
||||
|
||||
async function findPackageRelease(
|
||||
ctx: Pick<QueryCtx, "db">,
|
||||
pkg: Doc<"packages">,
|
||||
version?: string,
|
||||
) {
|
||||
if (version?.trim()) {
|
||||
return await ctx.db
|
||||
.query("packageReleases")
|
||||
.withIndex("by_package_version", (q) =>
|
||||
q.eq("packageId", pkg._id).eq("version", version.trim()),
|
||||
)
|
||||
.unique();
|
||||
}
|
||||
return pkg.latestReleaseId ? await ctx.db.get(pkg.latestReleaseId) : null;
|
||||
}
|
||||
|
||||
async function findActiveQueuedRequest(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
params: { entityType: SecurityScanEntityType; targetId: string; now: number },
|
||||
) {
|
||||
return await ctx.db
|
||||
.query("securityScanRequests")
|
||||
.withIndex("by_entity_target_status_expires", (q) =>
|
||||
q
|
||||
.eq("entityType", params.entityType)
|
||||
.eq("targetId", params.targetId)
|
||||
.eq("status", "queued")
|
||||
.gt("expiresAt", params.now),
|
||||
)
|
||||
.first();
|
||||
}
|
||||
|
||||
async function recordSecurityRescanRequest(
|
||||
ctx: MutationCtx,
|
||||
params: {
|
||||
actorUserId: Id<"users">;
|
||||
entityType: SecurityScanEntityType;
|
||||
targetId: string;
|
||||
targetLabel: string;
|
||||
version?: string;
|
||||
scanners: string[];
|
||||
now: number;
|
||||
},
|
||||
) {
|
||||
const existing = await findActiveQueuedRequest(ctx, params);
|
||||
if (existing) {
|
||||
return { existing, inserted: false as const };
|
||||
}
|
||||
const requestId = await ctx.db.insert("securityScanRequests", {
|
||||
entityType: params.entityType,
|
||||
targetId: params.targetId,
|
||||
targetLabel: params.targetLabel,
|
||||
version: params.version,
|
||||
status: "queued",
|
||||
scanners: params.scanners,
|
||||
requestedByUserId: params.actorUserId,
|
||||
createdAt: params.now,
|
||||
expiresAt: params.now + RESCAN_REQUEST_TTL_MS,
|
||||
});
|
||||
const inserted = await ctx.db.get(requestId);
|
||||
if (!inserted) throw new ConvexError("Failed to record security rescan request");
|
||||
return { existing: inserted, inserted: true as const };
|
||||
}
|
||||
|
||||
async function requestSkillRescan(ctx: MutationCtx, actorUserId: Id<"users">, slug: string) {
|
||||
const actor = await requireModeratorById(ctx, actorUserId);
|
||||
const skill = await findSkillBySlug(ctx, slug);
|
||||
if (!skill || skill.softDeletedAt || !skill.latestVersionId) {
|
||||
return {
|
||||
ok: false as const,
|
||||
state: "target_not_found" as const,
|
||||
entityType: "skill" as const,
|
||||
target: slug,
|
||||
scheduledScanners: [],
|
||||
};
|
||||
}
|
||||
const version = await ctx.db.get(skill.latestVersionId);
|
||||
if (!version || version.softDeletedAt) {
|
||||
return {
|
||||
ok: false as const,
|
||||
state: "target_not_found" as const,
|
||||
entityType: "skill" as const,
|
||||
target: skill.slug,
|
||||
scheduledScanners: [],
|
||||
};
|
||||
}
|
||||
|
||||
const scanners = ["static", "clawscan", "virustotal"];
|
||||
const now = Date.now();
|
||||
const request = await recordSecurityRescanRequest(ctx, {
|
||||
actorUserId,
|
||||
entityType: "skill",
|
||||
targetId: String(version._id),
|
||||
targetLabel: skill.slug,
|
||||
version: version.version,
|
||||
scanners,
|
||||
now,
|
||||
});
|
||||
if (!request.inserted) {
|
||||
return {
|
||||
ok: true as const,
|
||||
state: "already_in_progress" as const,
|
||||
entityType: "skill" as const,
|
||||
target: skill.slug,
|
||||
version: version.version,
|
||||
scheduledScanners: [],
|
||||
};
|
||||
}
|
||||
|
||||
await ctx.scheduler.runAfter(0, internal.skills.scanSkillVersionStaticallyInternal, {
|
||||
skillId: skill._id,
|
||||
versionId: version._id,
|
||||
});
|
||||
await ctx.scheduler.runAfter(0, internal.securityScan.enqueueSkillVersionScanInternal, {
|
||||
versionId: version._id,
|
||||
source: "manual",
|
||||
waitForVtMs: 0,
|
||||
});
|
||||
await ctx.scheduler.runAfter(0, internal.vt.scanWithVirusTotal, { versionId: version._id });
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: actor._id,
|
||||
action: "security.skill.rescan",
|
||||
targetType: "skillVersion",
|
||||
targetId: version._id,
|
||||
metadata: {
|
||||
skillId: skill._id,
|
||||
slug: skill.slug,
|
||||
version: version.version,
|
||||
scanners,
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
state: "queued" as const,
|
||||
entityType: "skill" as const,
|
||||
target: skill.slug,
|
||||
version: version.version,
|
||||
scheduledScanners: scanners,
|
||||
};
|
||||
}
|
||||
|
||||
async function requestPluginRescan(
|
||||
ctx: MutationCtx,
|
||||
actorUserId: Id<"users">,
|
||||
name: string,
|
||||
version?: string,
|
||||
) {
|
||||
const actor = await requireModeratorById(ctx, actorUserId);
|
||||
const pkg = await findPackageByName(ctx, name);
|
||||
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") {
|
||||
return {
|
||||
ok: false as const,
|
||||
state: "target_not_found" as const,
|
||||
entityType: "plugin" as const,
|
||||
target: name,
|
||||
scheduledScanners: [],
|
||||
};
|
||||
}
|
||||
const release = await findPackageRelease(ctx, pkg, version);
|
||||
if (!release || release.softDeletedAt) {
|
||||
return {
|
||||
ok: false as const,
|
||||
state: "target_not_found" as const,
|
||||
entityType: "plugin" as const,
|
||||
target: pkg.name,
|
||||
scheduledScanners: [],
|
||||
};
|
||||
}
|
||||
|
||||
const scanners = ["static", "clawscan", "virustotal"];
|
||||
const now = Date.now();
|
||||
const request = await recordSecurityRescanRequest(ctx, {
|
||||
actorUserId,
|
||||
entityType: "plugin",
|
||||
targetId: String(release._id),
|
||||
targetLabel: pkg.name,
|
||||
version: release.version,
|
||||
scanners,
|
||||
now,
|
||||
});
|
||||
if (!request.inserted) {
|
||||
return {
|
||||
ok: true as const,
|
||||
state: "already_in_progress" as const,
|
||||
entityType: "plugin" as const,
|
||||
target: pkg.name,
|
||||
version: release.version,
|
||||
scheduledScanners: [],
|
||||
};
|
||||
}
|
||||
|
||||
await ctx.scheduler.runAfter(0, internal.packages.scanPackageReleaseStaticallyInternal, {
|
||||
releaseId: release._id,
|
||||
});
|
||||
await ctx.scheduler.runAfter(0, internal.securityScan.enqueuePackageReleaseScanInternal, {
|
||||
releaseId: release._id,
|
||||
source: "manual",
|
||||
waitForVtMs: 0,
|
||||
});
|
||||
await ctx.scheduler.runAfter(0, internal.vt.scanPackageReleaseWithVirusTotal, {
|
||||
releaseId: release._id,
|
||||
});
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: actor._id,
|
||||
action: "security.plugin.rescan",
|
||||
targetType: "packageRelease",
|
||||
targetId: release._id,
|
||||
metadata: {
|
||||
packageId: pkg._id,
|
||||
name: pkg.name,
|
||||
version: release.version,
|
||||
scanners,
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
state: "queued" as const,
|
||||
entityType: "plugin" as const,
|
||||
target: pkg.name,
|
||||
version: release.version,
|
||||
scheduledScanners: scanners,
|
||||
};
|
||||
}
|
||||
|
||||
export const getSecurityScanSummaryForStaff = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const { user } = await requireUser(ctx);
|
||||
assertModerator(user);
|
||||
return await readSecurityScanSummary(ctx);
|
||||
},
|
||||
});
|
||||
|
||||
export const getSecurityScanSummaryForStaffInternal = internalQuery({
|
||||
args: { actorUserId: v.id("users") },
|
||||
handler: async (ctx, args) => {
|
||||
await requireModeratorById(ctx, args.actorUserId);
|
||||
return await readSecurityScanSummary(ctx);
|
||||
},
|
||||
});
|
||||
|
||||
export const listSecurityScanItemsForStaff = query({
|
||||
args: {
|
||||
entityType: securityScanEntityTypeValidator,
|
||||
status: securityScanStatusValidator,
|
||||
paginationOpts: paginationOptsValidator,
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx);
|
||||
assertModerator(user);
|
||||
const page = await ctx.db
|
||||
.query("securityScanEntityStates")
|
||||
.withIndex("by_entity_status_updated", (q) =>
|
||||
q.eq("entityType", args.entityType).eq("status", args.status),
|
||||
)
|
||||
.order("desc")
|
||||
.paginate(args.paginationOpts);
|
||||
return {
|
||||
...page,
|
||||
page: page.page.map((item) => ({
|
||||
entityType: item.entityType,
|
||||
targetId: item.targetId,
|
||||
label: item.label,
|
||||
status: item.status,
|
||||
updatedAt: item.updatedAt,
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const requestSkillSecurityRescanForStaff = mutation({
|
||||
args: { slug: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const { userId } = await requireUser(ctx);
|
||||
return await requestSkillRescan(ctx, userId, args.slug);
|
||||
},
|
||||
});
|
||||
|
||||
export const requestPluginSecurityRescanForStaff = mutation({
|
||||
args: { name: v.string(), version: v.optional(v.string()) },
|
||||
handler: async (ctx, args) => {
|
||||
const { userId } = await requireUser(ctx);
|
||||
return await requestPluginRescan(ctx, userId, args.name, args.version);
|
||||
},
|
||||
});
|
||||
|
||||
export const requestSkillSecurityRescanForStaffInternal = internalMutation({
|
||||
args: { actorUserId: v.id("users"), slug: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
return await requestSkillRescan(ctx, args.actorUserId, args.slug);
|
||||
},
|
||||
});
|
||||
|
||||
export const requestPluginSecurityRescanForStaffInternal = internalMutation({
|
||||
args: { actorUserId: v.id("users"), name: v.string(), version: v.optional(v.string()) },
|
||||
handler: async (ctx, args) => {
|
||||
return await requestPluginRescan(ctx, args.actorUserId, args.name, args.version);
|
||||
},
|
||||
});
|
||||
|
||||
export const refreshSkillSecurityScanStateInternal = internalMutation({
|
||||
args: { skillId: v.id("skills") },
|
||||
handler: async (ctx, args) => {
|
||||
const skill = await ctx.db.get(args.skillId);
|
||||
if (!skill) return { ok: true as const, skipped: "missing" as const };
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "skill",
|
||||
targetId: String(skill._id),
|
||||
label: skill.slug,
|
||||
status: securityScanStatusFromSkill(skill),
|
||||
});
|
||||
return { ok: true as const };
|
||||
},
|
||||
});
|
||||
|
||||
export const refreshPackageSecurityScanStateInternal = internalMutation({
|
||||
args: { packageId: v.id("packages") },
|
||||
handler: async (ctx, args) => {
|
||||
const pkg = await ctx.db.get(args.packageId);
|
||||
if (!pkg) return { ok: true as const, skipped: "missing" as const };
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "plugin",
|
||||
targetId: String(pkg._id),
|
||||
label: pkg.name,
|
||||
status:
|
||||
pkg.family === "skill"
|
||||
? null
|
||||
: securityScanStatusFromPackage({
|
||||
softDeletedAt: pkg.softDeletedAt,
|
||||
scanStatus: pkg.scanStatus,
|
||||
}),
|
||||
});
|
||||
return { ok: true as const };
|
||||
},
|
||||
});
|
||||
|
||||
type SecurityScanRebuildPage<T extends "skills" | "packages"> = {
|
||||
ids: Id<T>[];
|
||||
continueCursor: string;
|
||||
isDone: boolean;
|
||||
};
|
||||
|
||||
export const rebuildSecurityScanRollupsInternal: ReturnType<typeof internalAction> = internalAction(
|
||||
{
|
||||
args: {
|
||||
skillCursor: v.optional(v.union(v.string(), v.null())),
|
||||
packageCursor: v.optional(v.union(v.string(), v.null())),
|
||||
batchSize: v.optional(v.number()),
|
||||
synced: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<{ ok: true; synced: number; done: boolean }> => {
|
||||
const batchSize = Math.max(1, Math.min(Math.floor(args.batchSize ?? 100), 200));
|
||||
let synced = args.synced ?? 0;
|
||||
const skills = (await ctx.runQuery(internal.securityScans.getSecurityScanSkillRebuildPage, {
|
||||
cursor: args.skillCursor ?? null,
|
||||
batchSize,
|
||||
})) as SecurityScanRebuildPage<"skills">;
|
||||
for (const skillId of skills.ids) {
|
||||
await ctx.runMutation(internal.securityScans.refreshSkillSecurityScanStateInternal, {
|
||||
skillId,
|
||||
});
|
||||
synced += 1;
|
||||
}
|
||||
const packages = (await ctx.runQuery(
|
||||
internal.securityScans.getSecurityScanPackageRebuildPage,
|
||||
{
|
||||
cursor: args.packageCursor ?? null,
|
||||
batchSize,
|
||||
},
|
||||
)) as SecurityScanRebuildPage<"packages">;
|
||||
for (const packageId of packages.ids) {
|
||||
await ctx.runMutation(internal.securityScans.refreshPackageSecurityScanStateInternal, {
|
||||
packageId,
|
||||
});
|
||||
synced += 1;
|
||||
}
|
||||
if (!skills.isDone || !packages.isDone) {
|
||||
await ctx.scheduler.runAfter(0, internal.securityScans.rebuildSecurityScanRollupsInternal, {
|
||||
skillCursor: skills.isDone ? skills.continueCursor : skills.continueCursor,
|
||||
packageCursor: packages.isDone ? packages.continueCursor : packages.continueCursor,
|
||||
batchSize,
|
||||
synced,
|
||||
});
|
||||
}
|
||||
const done = skills.isDone && packages.isDone;
|
||||
if (done) {
|
||||
await ctx.runMutation(
|
||||
internal.securityScans.markSecurityScanRollupRebuildCompleteInternal,
|
||||
{
|
||||
completedAt: Date.now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
return { ok: true as const, synced, done };
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const markSecurityScanRollupRebuildCompleteInternal = internalMutation({
|
||||
args: { completedAt: v.number() },
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db
|
||||
.query("securityScanRollupMetadata")
|
||||
.withIndex("by_key", (q) => q.eq("key", SECURITY_SCAN_ROLLUP_REBUILD_KEY))
|
||||
.unique();
|
||||
const doc = {
|
||||
key: SECURITY_SCAN_ROLLUP_REBUILD_KEY,
|
||||
completedAt: args.completedAt,
|
||||
updatedAt: args.completedAt,
|
||||
};
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, doc);
|
||||
return existing._id;
|
||||
}
|
||||
return await ctx.db.insert("securityScanRollupMetadata", doc);
|
||||
},
|
||||
});
|
||||
|
||||
export const rebuildSecurityScanRollupsForStaff: ReturnType<typeof action> = action({
|
||||
args: { batchSize: v.optional(v.number()) },
|
||||
handler: async (ctx, args): Promise<{ ok: true; synced: number; done: boolean }> => {
|
||||
const { user } = await requireUserFromAction(ctx);
|
||||
assertModerator(user);
|
||||
return await ctx.runAction(internal.securityScans.rebuildSecurityScanRollupsInternal, {
|
||||
batchSize: args.batchSize,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const getSecurityScanSkillRebuildPage = internalQuery({
|
||||
args: { cursor: v.union(v.string(), v.null()), batchSize: v.number() },
|
||||
handler: async (ctx, args) => {
|
||||
const page = await ctx.db.query("skills").paginate({
|
||||
cursor: args.cursor,
|
||||
numItems: args.batchSize,
|
||||
});
|
||||
return {
|
||||
ids: page.page.map((skill) => skill._id),
|
||||
continueCursor: page.continueCursor,
|
||||
isDone: page.isDone,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getSecurityScanPackageRebuildPage = internalQuery({
|
||||
args: { cursor: v.union(v.string(), v.null()), batchSize: v.number() },
|
||||
handler: async (ctx, args) => {
|
||||
const page = await ctx.db.query("packages").paginate({
|
||||
cursor: args.cursor,
|
||||
numItems: args.batchSize,
|
||||
});
|
||||
return {
|
||||
ids: page.page.map((pkg) => pkg._id),
|
||||
continueCursor: page.continueCursor,
|
||||
isDone: page.isDone,
|
||||
};
|
||||
},
|
||||
});
|
||||
+142
-4
@@ -98,6 +98,10 @@ import {
|
||||
upsertReservedSlugForRightfulOwner,
|
||||
} from "./lib/reservedSlugs";
|
||||
import { matchesAllTokens, matchesExploratoryTokenPrefixes, tokenize } from "./lib/searchText";
|
||||
import {
|
||||
securityScanStatusFromSkill,
|
||||
syncSecurityScanEntityState,
|
||||
} from "./lib/securityScanRollups";
|
||||
import { SKILL_CAPABILITY_TAGS } from "./lib/skillCapabilityTags";
|
||||
import { normalizeSkillIconValue } from "./lib/skillIcon";
|
||||
import {
|
||||
@@ -454,6 +458,12 @@ async function patchStructuredModerationFromVersion(
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "skill",
|
||||
targetId: String(skill._id),
|
||||
label: skill.slug,
|
||||
status: securityScanStatusFromSkill(nextSkill),
|
||||
});
|
||||
}
|
||||
const TRUSTED_PUBLISHER_SKILL_THRESHOLD = 10;
|
||||
const LOW_TRUST_BURST_THRESHOLD_PER_HOUR = 8;
|
||||
@@ -644,6 +654,12 @@ async function syncSkillModerationFromLatestVersion(
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "skill",
|
||||
targetId: String(skill._id),
|
||||
label: skill.slug,
|
||||
status: securityScanStatusFromSkill(nextSkill),
|
||||
});
|
||||
}
|
||||
|
||||
function buildConflictingSkillUrl(skill: Doc<"skills">, owner: SkillOwnerRef) {
|
||||
@@ -1231,6 +1247,12 @@ async function hardDeleteSkillStep(
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "skill",
|
||||
targetId: String(skill._id),
|
||||
label: skill.slug,
|
||||
status: securityScanStatusFromSkill(nextSkill),
|
||||
});
|
||||
}
|
||||
|
||||
switch (phase) {
|
||||
@@ -2511,6 +2533,12 @@ export const clearOwnerSuspiciousFlagsInternal = internalMutation({
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "skill",
|
||||
targetId: String(skill._id),
|
||||
label: skill.slug,
|
||||
status: securityScanStatusFromSkill(nextSkill),
|
||||
});
|
||||
updated += 1;
|
||||
}
|
||||
|
||||
@@ -3226,6 +3254,12 @@ export const report = mutation({
|
||||
await ctx.db.patch(skill._id, updates);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "skill",
|
||||
targetId: String(skill._id),
|
||||
label: skill.slug,
|
||||
status: securityScanStatusFromSkill(nextSkill),
|
||||
});
|
||||
|
||||
if (shouldAutoHide) {
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, now);
|
||||
@@ -3536,6 +3570,12 @@ async function applySkillReportFinalAction(
|
||||
const nextSkill = { ...params.skill, ...patch };
|
||||
await ctx.db.patch(params.skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, params.skill, nextSkill);
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "skill",
|
||||
targetId: String(params.skill._id),
|
||||
label: params.skill.slug,
|
||||
status: securityScanStatusFromSkill(nextSkill),
|
||||
});
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, params.skill._id, true, params.now);
|
||||
|
||||
await ctx.db.insert("auditLogs", {
|
||||
@@ -3589,6 +3629,12 @@ async function applySkillAppealFinalAction(
|
||||
const nextSkill = { ...params.skill, ...patch };
|
||||
await ctx.db.patch(params.skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, params.skill, nextSkill);
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "skill",
|
||||
targetId: String(params.skill._id),
|
||||
label: params.skill.slug,
|
||||
status: securityScanStatusFromSkill(nextSkill),
|
||||
});
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, params.skill._id, false, params.now);
|
||||
|
||||
await ctx.db.insert("auditLogs", {
|
||||
@@ -5703,7 +5749,7 @@ export const hideObviousJunkSuspiciousSkillsInternal = internalMutation({
|
||||
if (examples.length < 25) examples.push(skill.slug);
|
||||
if (dryRun || accHidden >= maxToHide) continue;
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
const patch: Partial<Doc<"skills">> = {
|
||||
softDeletedAt: now,
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "cleanup.obvious_junk",
|
||||
@@ -5712,6 +5758,14 @@ export const hideObviousJunkSuspiciousSkillsInternal = internalMutation({
|
||||
hiddenBy: undefined,
|
||||
lastReviewedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "skill",
|
||||
targetId: String(skill._id),
|
||||
label: skill.slug,
|
||||
status: securityScanStatusFromSkill(nextSkill),
|
||||
});
|
||||
accHidden++;
|
||||
}
|
||||
@@ -5944,6 +5998,12 @@ export const updateSkillVersionStaticScanInternal = internalMutation({
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "skill",
|
||||
targetId: String(skill._id),
|
||||
label: skill.slug,
|
||||
status: securityScanStatusFromSkill(nextSkill),
|
||||
});
|
||||
|
||||
if (patch.moderationVerdict === "malicious" && skill.ownerUserId) {
|
||||
const trigger =
|
||||
@@ -6243,6 +6303,12 @@ export const escalateSkillByIdInternal = internalMutation({
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "skill",
|
||||
targetId: String(skill._id),
|
||||
label: skill.slug,
|
||||
status: securityScanStatusFromSkill(nextSkill),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6256,13 +6322,23 @@ export const updateSkillModerationReasonInternal = internalMutation({
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const skill = await ctx.db.get(args.skillId);
|
||||
await ctx.db.patch(args.skillId, {
|
||||
const patch: Partial<Doc<"skills">> = {
|
||||
moderationReason: args.moderationReason,
|
||||
isSuspicious: computeIsSuspicious({
|
||||
moderationFlags: skill?.moderationFlags,
|
||||
moderationReason: args.moderationReason,
|
||||
}),
|
||||
});
|
||||
};
|
||||
await ctx.db.patch(args.skillId, patch);
|
||||
if (skill) {
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "skill",
|
||||
targetId: String(skill._id),
|
||||
label: skill.slug,
|
||||
status: securityScanStatusFromSkill(nextSkill),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6427,6 +6503,12 @@ export const applyBanToOwnedSkillsBatchInternal = internalMutation({
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "skill",
|
||||
targetId: String(skill._id),
|
||||
label: skill.slug,
|
||||
status: securityScanStatusFromSkill(nextSkill),
|
||||
});
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, args.bannedAt);
|
||||
}
|
||||
|
||||
@@ -6495,6 +6577,12 @@ export const applyUserModerationToOwnedSkillsBatchInternal = internalMutation({
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "skill",
|
||||
targetId: String(skill._id),
|
||||
label: skill.slug,
|
||||
status: securityScanStatusFromSkill(nextSkill),
|
||||
});
|
||||
hiddenCount += 1;
|
||||
}
|
||||
|
||||
@@ -6554,6 +6642,12 @@ export const restoreOwnedSkillsForUnbanBatchInternal = internalMutation({
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "skill",
|
||||
targetId: String(skill._id),
|
||||
label: skill.slug,
|
||||
status: securityScanStatusFromSkill(nextSkill),
|
||||
});
|
||||
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, false, now);
|
||||
restoredCount += 1;
|
||||
@@ -6656,6 +6750,12 @@ export const restoreOwnedSkillsForModerationLiftBatchInternal = internalMutation
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "skill",
|
||||
targetId: String(skill._id),
|
||||
label: skill.slug,
|
||||
status: securityScanStatusFromSkill(nextSkill),
|
||||
});
|
||||
restoredCount += 1;
|
||||
}
|
||||
|
||||
@@ -6769,13 +6869,21 @@ export const markScanStaleInternal = internalMutation({
|
||||
const skill = await ctx.db.get(args.skillId);
|
||||
if (!skill) return;
|
||||
|
||||
await ctx.db.patch(args.skillId, {
|
||||
const patch: Partial<Doc<"skills">> = {
|
||||
moderationReason: "pending.scan.stale",
|
||||
isSuspicious: computeIsSuspicious({
|
||||
moderationFlags: skill.moderationFlags,
|
||||
moderationReason: "pending.scan.stale",
|
||||
}),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(args.skillId, patch);
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "skill",
|
||||
targetId: String(skill._id),
|
||||
label: skill.slug,
|
||||
status: securityScanStatusFromSkill(nextSkill),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -7021,6 +7129,12 @@ export const approveSkillByHashInternal = internalMutation({
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "skill",
|
||||
targetId: String(skill._id),
|
||||
label: skill.slug,
|
||||
status: securityScanStatusFromSkill(nextSkill),
|
||||
});
|
||||
|
||||
// Auto-ban authors of malicious skills (skips moderators/admins)
|
||||
if (nextVerdict === "malicious" && skill.ownerUserId) {
|
||||
@@ -7164,6 +7278,12 @@ export const escalateByVtInternal = internalMutation({
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "skill",
|
||||
targetId: String(skill._id),
|
||||
label: skill.slug,
|
||||
status: securityScanStatusFromSkill(nextSkill),
|
||||
});
|
||||
|
||||
// Auto-ban authors of malicious skills
|
||||
if (nextVerdict === "malicious" && skill.ownerUserId) {
|
||||
@@ -7674,6 +7794,12 @@ export const setSkillManualOverride = mutation({
|
||||
});
|
||||
const nextSkill = { ...skill, manualOverride, ...patch };
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "skill",
|
||||
targetId: String(skill._id),
|
||||
label: skill.slug,
|
||||
status: securityScanStatusFromSkill(nextSkill),
|
||||
});
|
||||
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: user._id,
|
||||
@@ -7770,6 +7896,12 @@ export const setSoftDeleted = mutation({
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "skill",
|
||||
targetId: String(skill._id),
|
||||
label: skill.slug,
|
||||
status: securityScanStatusFromSkill(nextSkill),
|
||||
});
|
||||
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, args.deleted, now);
|
||||
|
||||
@@ -9544,6 +9676,12 @@ export const insertVersion = internalMutation({
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await syncSecurityScanEntityState(ctx, {
|
||||
entityType: "skill",
|
||||
targetId: String(skill._id),
|
||||
label: skill.slug,
|
||||
status: securityScanStatusFromSkill(nextSkill),
|
||||
});
|
||||
|
||||
if (moderationSnapshot.verdict === "malicious" && skill.ownerUserId) {
|
||||
const trigger =
|
||||
|
||||
@@ -98,12 +98,15 @@ bun run mod -- set-role <handleOrId> <user|moderator|admin>
|
||||
Package moderation and operations:
|
||||
|
||||
```bash
|
||||
bun run mod -- security summary [--json]
|
||||
|
||||
bun run mod -- skills reports [--status open|confirmed|dismissed|all]
|
||||
bun run mod -- skills rescan <slug> [--yes]
|
||||
bun run mod -- skills unhide <slug> --reason <text> [--yes]
|
||||
bun run mod -- skills triage-report <report-id> --status open|confirmed|dismissed [--note <text>] [--action none|hide] [--yes]
|
||||
|
||||
bun run mod -- plugins moderate <name> --version <version> --state approved|quarantined|revoked --reason <text>
|
||||
bun run mod -- plugins rescan <name> [--yes]
|
||||
bun run mod -- plugins rescan <name> [--version <version>] [--yes]
|
||||
bun run mod -- plugins status <name>
|
||||
bun run mod -- plugins queue [--status open|blocked|manual|all]
|
||||
bun run mod -- plugins reports [--status open|confirmed|dismissed|all]
|
||||
|
||||
@@ -35,6 +35,11 @@ import {
|
||||
cmdTriagePackageReport,
|
||||
cmdUpsertPackageMigration,
|
||||
} from "./commands/packages.js";
|
||||
import {
|
||||
cmdPluginSecurityRescan,
|
||||
cmdSecuritySummary,
|
||||
cmdSkillSecurityRescan,
|
||||
} from "./commands/security.js";
|
||||
|
||||
const program = new Command()
|
||||
.name("clawhub-mod")
|
||||
@@ -284,6 +289,21 @@ registerPluginModerationCommands(plugins);
|
||||
registerPluginGovernanceCommands(plugins);
|
||||
registerSkillModerationCommands(skills);
|
||||
|
||||
const security = program
|
||||
.command("security")
|
||||
.description("Security scan operations")
|
||||
.showHelpAfterError()
|
||||
.showSuggestionAfterError();
|
||||
|
||||
security
|
||||
.command("summary")
|
||||
.description("Show staff security scan counts")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdSecuritySummary(opts, options);
|
||||
});
|
||||
|
||||
function registerPluginGovernanceCommands(command: Command) {
|
||||
command
|
||||
.command("backfill-artifacts")
|
||||
@@ -427,6 +447,18 @@ function registerPluginModerationCommands(command: Command) {
|
||||
}
|
||||
|
||||
function registerPluginOperations(command: Command) {
|
||||
command
|
||||
.command("rescan")
|
||||
.description("Request a staff security rescan for a plugin package")
|
||||
.argument("<name>", "Plugin package name")
|
||||
.option("--version <version>", "Plugin package version; defaults to latest")
|
||||
.option("--yes", "Skip confirmation")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (name, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdPluginSecurityRescan(opts, name, options, isInputAllowed());
|
||||
});
|
||||
|
||||
command
|
||||
.command("moderate")
|
||||
.description("Set plugin release moderation state")
|
||||
@@ -468,6 +500,17 @@ function registerPluginOperations(command: Command) {
|
||||
}
|
||||
|
||||
function registerSkillModerationCommands(command: Command) {
|
||||
command
|
||||
.command("rescan")
|
||||
.description("Request a staff security rescan for a skill")
|
||||
.argument("<slug>", "Skill slug")
|
||||
.option("--yes", "Skip confirmation")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (slug, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdSkillSecurityRescan(opts, slug, options, isInputAllowed());
|
||||
});
|
||||
|
||||
command
|
||||
.command("unhide")
|
||||
.description("Manually restore a hidden skill after moderator review")
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createAuthTokenModuleMocks,
|
||||
createHttpModuleMocks,
|
||||
createRegistryModuleMocks,
|
||||
createUiModuleMocks,
|
||||
makeGlobalOpts,
|
||||
} from "../../../clawhub/test/cliCommandTestKit.js";
|
||||
|
||||
const authTokenMocks = createAuthTokenModuleMocks();
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const httpMocks = createHttpModuleMocks();
|
||||
const uiMocks = createUiModuleMocks();
|
||||
|
||||
vi.mock("../../../clawhub/src/cli/authToken.js", () => authTokenMocks.moduleFactory());
|
||||
vi.mock("../../../clawhub/src/cli/registry.js", () => registryMocks.moduleFactory());
|
||||
vi.mock("../../../clawhub/src/http.js", () => httpMocks.moduleFactory());
|
||||
vi.mock("../../../clawhub/src/cli/ui.js", () => uiMocks.moduleFactory());
|
||||
|
||||
const { cmdPluginSecurityRescan, cmdSecuritySummary, cmdSkillSecurityRescan } =
|
||||
await import("./security");
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("cmdSecuritySummary", () => {
|
||||
it("fetches the staff security summary", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
generatedAt: 123,
|
||||
updatedAt: 122,
|
||||
totals: {
|
||||
skills: { benign: 1, suspicious: 2, malicious: 3, pending: 4, unknown: 5 },
|
||||
plugins: { benign: 6, suspicious: 7, malicious: 8, pending: 9, unknown: 10 },
|
||||
},
|
||||
stale: false,
|
||||
});
|
||||
|
||||
await cmdSecuritySummary(makeGlobalOpts(), {});
|
||||
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
path: "/api/v1/security/summary",
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("security rescan commands", () => {
|
||||
it("requires --yes for non-interactive skill rescans", async () => {
|
||||
await expect(cmdSkillSecurityRescan(makeGlobalOpts(), "demo", {}, false)).rejects.toThrow(
|
||||
/--yes/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("posts skill rescan requests", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
state: "queued",
|
||||
entityType: "skill",
|
||||
target: "demo",
|
||||
scheduledScanners: ["static", "clawscan", "virustotal"],
|
||||
});
|
||||
|
||||
await cmdSkillSecurityRescan(makeGlobalOpts(), "demo", { yes: true }, false);
|
||||
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
path: "/api/v1/security/skills/demo/rescan",
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("posts plugin rescan requests with an optional version", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
state: "queued",
|
||||
entityType: "plugin",
|
||||
target: "@scope/demo",
|
||||
version: "1.2.3",
|
||||
scheduledScanners: ["static", "clawscan", "virustotal"],
|
||||
});
|
||||
|
||||
await cmdPluginSecurityRescan(
|
||||
makeGlobalOpts(),
|
||||
"@scope/demo",
|
||||
{ version: "1.2.3", yes: true },
|
||||
false,
|
||||
);
|
||||
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
path: "/api/v1/security/plugins/%40scope%2Fdemo/rescan",
|
||||
body: { version: "1.2.3" },
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { requireAuthToken } from "../../../clawhub/src/cli/authToken.js";
|
||||
import { getRegistry } from "../../../clawhub/src/cli/registry.js";
|
||||
import type { GlobalOpts } from "../../../clawhub/src/cli/types.js";
|
||||
import {
|
||||
createSpinner,
|
||||
fail,
|
||||
formatError,
|
||||
isInteractive,
|
||||
promptConfirm,
|
||||
} from "../../../clawhub/src/cli/ui.js";
|
||||
import { apiRequest } from "../../../clawhub/src/http.js";
|
||||
import {
|
||||
ApiRoutes,
|
||||
ApiV1SecurityRescanResponseSchema,
|
||||
ApiV1SecurityScanSummaryResponseSchema,
|
||||
parseArk,
|
||||
type ApiV1SecurityScanSummaryResponse,
|
||||
} from "../../../clawhub/src/schema/index.js";
|
||||
|
||||
type SecuritySummaryOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type SecurityRescanOptions = {
|
||||
version?: string;
|
||||
yes?: boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
export async function cmdSecuritySummary(opts: GlobalOpts, options: SecuritySummaryOptions = {}) {
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{
|
||||
method: "GET",
|
||||
path: `${ApiRoutes.security}/summary`,
|
||||
token,
|
||||
},
|
||||
ApiV1SecurityScanSummaryResponseSchema,
|
||||
);
|
||||
const parsed = parseArk(
|
||||
ApiV1SecurityScanSummaryResponseSchema,
|
||||
result,
|
||||
"Security scan summary response",
|
||||
);
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(parsed, null, 2)}\n`);
|
||||
return parsed;
|
||||
}
|
||||
printSecuritySummary(parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function cmdSkillSecurityRescan(
|
||||
opts: GlobalOpts,
|
||||
slug: string,
|
||||
options: SecurityRescanOptions = {},
|
||||
inputAllowed = true,
|
||||
) {
|
||||
const trimmed = slug.trim();
|
||||
if (!trimmed) fail("Skill slug required");
|
||||
await confirmRescan(`skill ${trimmed}`, options, inputAllowed);
|
||||
return await postSecurityRescan(opts, {
|
||||
path: `${ApiRoutes.security}/skills/${encodeURIComponent(trimmed)}/rescan`,
|
||||
label: trimmed,
|
||||
options,
|
||||
});
|
||||
}
|
||||
|
||||
export async function cmdPluginSecurityRescan(
|
||||
opts: GlobalOpts,
|
||||
name: string,
|
||||
options: SecurityRescanOptions = {},
|
||||
inputAllowed = true,
|
||||
) {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) fail("Plugin package name required");
|
||||
await confirmRescan(`plugin ${trimmed}`, options, inputAllowed);
|
||||
return await postSecurityRescan(opts, {
|
||||
path: `${ApiRoutes.security}/plugins/${encodeURIComponent(trimmed)}/rescan`,
|
||||
label: options.version?.trim() ? `${trimmed}@${options.version.trim()}` : trimmed,
|
||||
body: options.version?.trim() ? { version: options.version.trim() } : undefined,
|
||||
options,
|
||||
});
|
||||
}
|
||||
|
||||
async function postSecurityRescan(
|
||||
opts: GlobalOpts,
|
||||
params: {
|
||||
path: string;
|
||||
label: string;
|
||||
body?: { version: string };
|
||||
options: SecurityRescanOptions;
|
||||
},
|
||||
) {
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const spinner = params.options.json
|
||||
? null
|
||||
: createSpinner(`Requesting rescan for ${params.label}`);
|
||||
try {
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{
|
||||
method: "POST",
|
||||
path: params.path,
|
||||
token,
|
||||
...(params.body ? { body: params.body } : {}),
|
||||
},
|
||||
ApiV1SecurityRescanResponseSchema,
|
||||
);
|
||||
const parsed = parseArk(ApiV1SecurityRescanResponseSchema, result, "Security rescan response");
|
||||
spinner?.stop();
|
||||
if (params.options.json) {
|
||||
process.stdout.write(`${JSON.stringify(parsed, null, 2)}\n`);
|
||||
return parsed;
|
||||
}
|
||||
if (parsed.state === "already_in_progress") {
|
||||
console.log(
|
||||
`Rescan already in progress for ${parsed.target}${formatVersion(parsed.version)}.`,
|
||||
);
|
||||
} else if (parsed.state === "queued") {
|
||||
console.log(
|
||||
`Queued ${parsed.scheduledScanners.join(", ")} rescan for ${parsed.target}${formatVersion(parsed.version)}.`,
|
||||
);
|
||||
} else {
|
||||
console.log(`Rescan not queued for ${parsed.target}: ${parsed.state}.`);
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
spinner?.fail(formatError(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRescan(label: string, options: SecurityRescanOptions, inputAllowed: boolean) {
|
||||
if (options.yes) return;
|
||||
if (!isInteractive() || inputAllowed === false) fail("Pass --yes (no input)");
|
||||
const ok = await promptConfirm(`Request security rescan for ${label}?`);
|
||||
if (!ok) fail("Cancelled");
|
||||
}
|
||||
|
||||
function printSecuritySummary(summary: ApiV1SecurityScanSummaryResponse) {
|
||||
console.log("Security scan summary");
|
||||
printCounts("skills", summary.totals.skills);
|
||||
printCounts("plugins", summary.totals.plugins);
|
||||
if (summary.stale) {
|
||||
console.log("Rollups need rebuild before counts are complete.");
|
||||
}
|
||||
}
|
||||
|
||||
function printCounts(label: string, counts: ApiV1SecurityScanSummaryResponse["totals"]["skills"]) {
|
||||
console.log(
|
||||
`${label}: benign ${counts.benign}, suspicious ${counts.suspicious}, malicious ${counts.malicious}, pending ${counts.pending}, unknown ${counts.unknown}`,
|
||||
);
|
||||
}
|
||||
|
||||
function formatVersion(version: string | undefined) {
|
||||
return version ? `@${version}` : "";
|
||||
}
|
||||
@@ -5,5 +5,6 @@ export { PLATFORM_SKILL_LICENSE, PLATFORM_SKILL_LICENSE_SUMMARY } from "./licens
|
||||
export * from "./openclawContract.js";
|
||||
export * from "./packages.js";
|
||||
export { ApiRoutes, LegacyApiRoutes } from "./routes.js";
|
||||
export * from "./security.js";
|
||||
export * from "./schemas.js";
|
||||
export * from "./textFiles.js";
|
||||
|
||||
@@ -23,6 +23,7 @@ export const ApiRoutes = {
|
||||
stars: "/api/v1/stars",
|
||||
transfers: "/api/v1/transfers",
|
||||
souls: "/api/v1/souls",
|
||||
security: "/api/v1/security",
|
||||
users: "/api/v1/users",
|
||||
whoami: "/api/v1/whoami",
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { type inferred, type } from "arktype";
|
||||
|
||||
export const SecurityScanStatusSchema = type(
|
||||
'"benign"|"suspicious"|"malicious"|"pending"|"unknown"',
|
||||
);
|
||||
export type SecurityScanStatus = (typeof SecurityScanStatusSchema)[inferred];
|
||||
|
||||
export const SecurityScanCountsSchema = type({
|
||||
benign: "number",
|
||||
suspicious: "number",
|
||||
malicious: "number",
|
||||
pending: "number",
|
||||
unknown: "number",
|
||||
});
|
||||
export type SecurityScanCounts = (typeof SecurityScanCountsSchema)[inferred];
|
||||
|
||||
export const ApiV1SecurityScanSummaryResponseSchema = type({
|
||||
generatedAt: "number",
|
||||
updatedAt: "number|null",
|
||||
stale: "boolean",
|
||||
totals: {
|
||||
skills: SecurityScanCountsSchema,
|
||||
plugins: SecurityScanCountsSchema,
|
||||
},
|
||||
});
|
||||
export type ApiV1SecurityScanSummaryResponse =
|
||||
(typeof ApiV1SecurityScanSummaryResponseSchema)[inferred];
|
||||
|
||||
export const ApiV1SecurityRescanResponseSchema = type({
|
||||
ok: "boolean",
|
||||
state: '"queued"|"already_in_progress"|"target_not_found"|"scanner_unavailable"',
|
||||
entityType: '"skill"|"plugin"',
|
||||
target: "string",
|
||||
version: "string?",
|
||||
scheduledScanners: "string[]",
|
||||
});
|
||||
export type ApiV1SecurityRescanResponse = (typeof ApiV1SecurityRescanResponseSchema)[inferred];
|
||||
Vendored
+1
@@ -7,5 +7,6 @@ export * from "./openclawContract.js";
|
||||
export * from "./packages.js";
|
||||
export * from "./pluginCategories.js";
|
||||
export { ApiRoutes, LegacyApiRoutes } from "./routes.js";
|
||||
export * from "./security.js";
|
||||
export * from "./schemas.js";
|
||||
export * from "./textFiles.js";
|
||||
|
||||
Vendored
+1
@@ -6,6 +6,7 @@ export * from "./openclawContract.js";
|
||||
export * from "./packages.js";
|
||||
export * from "./pluginCategories.js";
|
||||
export { ApiRoutes, LegacyApiRoutes } from "./routes.js";
|
||||
export * from "./security.js";
|
||||
export * from "./schemas.js";
|
||||
export * from "./textFiles.js";
|
||||
//# sourceMappingURL=index.js.map
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACrD,cAAc,mBAAmB,CAAC;AAClC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,cAAc,uBAAuB,CAAC;AACtC,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACzD,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC"}
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACrD,cAAc,mBAAmB,CAAC;AAClC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,cAAc,uBAAuB,CAAC;AACtC,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACzD,cAAc,eAAe,CAAC;AAC9B,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC"}
|
||||
Vendored
+1
@@ -23,6 +23,7 @@ export declare const ApiRoutes: {
|
||||
readonly stars: "/api/v1/stars";
|
||||
readonly transfers: "/api/v1/transfers";
|
||||
readonly souls: "/api/v1/souls";
|
||||
readonly security: "/api/v1/security";
|
||||
readonly users: "/api/v1/users";
|
||||
readonly whoami: "/api/v1/whoami";
|
||||
};
|
||||
|
||||
Vendored
+1
@@ -23,6 +23,7 @@ export const ApiRoutes = {
|
||||
stars: "/api/v1/stars",
|
||||
transfers: "/api/v1/transfers",
|
||||
souls: "/api/v1/souls",
|
||||
security: "/api/v1/security",
|
||||
users: "/api/v1/users",
|
||||
whoami: "/api/v1/whoami",
|
||||
};
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,gBAAgB,EAAE,4BAA4B;IAC9C,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;CAChB,CAAC"}
|
||||
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,gBAAgB,EAAE,4BAA4B;IAC9C,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,KAAK,EAAE,eAAe;IACtB,QAAQ,EAAE,kBAAkB;IAC5B,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;CAChB,CAAC"}
|
||||
Vendored
+42
@@ -0,0 +1,42 @@
|
||||
import { type inferred } from "arktype";
|
||||
export declare const SecurityScanStatusSchema: import("arktype/internal/variants/string.ts").StringType<"unknown" | "suspicious" | "malicious" | "pending" | "benign", {}>;
|
||||
export type SecurityScanStatus = (typeof SecurityScanStatusSchema)[inferred];
|
||||
export declare const SecurityScanCountsSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
benign: number;
|
||||
suspicious: number;
|
||||
malicious: number;
|
||||
pending: number;
|
||||
unknown: number;
|
||||
}, {}>;
|
||||
export type SecurityScanCounts = (typeof SecurityScanCountsSchema)[inferred];
|
||||
export declare const ApiV1SecurityScanSummaryResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
generatedAt: number;
|
||||
updatedAt: number | null;
|
||||
stale: boolean;
|
||||
totals: {
|
||||
skills: {
|
||||
benign: number;
|
||||
suspicious: number;
|
||||
malicious: number;
|
||||
pending: number;
|
||||
unknown: number;
|
||||
};
|
||||
plugins: {
|
||||
benign: number;
|
||||
suspicious: number;
|
||||
malicious: number;
|
||||
pending: number;
|
||||
unknown: number;
|
||||
};
|
||||
};
|
||||
}, {}>;
|
||||
export type ApiV1SecurityScanSummaryResponse = (typeof ApiV1SecurityScanSummaryResponseSchema)[inferred];
|
||||
export declare const ApiV1SecurityRescanResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
ok: boolean;
|
||||
state: "queued" | "already_in_progress" | "target_not_found" | "scanner_unavailable";
|
||||
entityType: "skill" | "plugin";
|
||||
target: string;
|
||||
scheduledScanners: string[];
|
||||
version?: string | undefined;
|
||||
}, {}>;
|
||||
export type ApiV1SecurityRescanResponse = (typeof ApiV1SecurityRescanResponseSchema)[inferred];
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
import { type } from "arktype";
|
||||
export const SecurityScanStatusSchema = type('"benign"|"suspicious"|"malicious"|"pending"|"unknown"');
|
||||
export const SecurityScanCountsSchema = type({
|
||||
benign: "number",
|
||||
suspicious: "number",
|
||||
malicious: "number",
|
||||
pending: "number",
|
||||
unknown: "number",
|
||||
});
|
||||
export const ApiV1SecurityScanSummaryResponseSchema = type({
|
||||
generatedAt: "number",
|
||||
updatedAt: "number|null",
|
||||
stale: "boolean",
|
||||
totals: {
|
||||
skills: SecurityScanCountsSchema,
|
||||
plugins: SecurityScanCountsSchema,
|
||||
},
|
||||
});
|
||||
export const ApiV1SecurityRescanResponseSchema = type({
|
||||
ok: "boolean",
|
||||
state: '"queued"|"already_in_progress"|"target_not_found"|"scanner_unavailable"',
|
||||
entityType: '"skill"|"plugin"',
|
||||
target: "string",
|
||||
version: "string?",
|
||||
scheduledScanners: "string[]",
|
||||
});
|
||||
//# sourceMappingURL=security.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"security.js","sourceRoot":"","sources":["../src/security.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,IAAI,EAAE,MAAM,SAAS,CAAC;AAE9C,MAAM,CAAC,MAAM,wBAAwB,GAAG,IAAI,CAC1C,uDAAuD,CACxD,CAAC;AAGF,MAAM,CAAC,MAAM,wBAAwB,GAAG,IAAI,CAAC;IAC3C,MAAM,EAAE,QAAQ;IAChB,UAAU,EAAE,QAAQ;IACpB,SAAS,EAAE,QAAQ;IACnB,OAAO,EAAE,QAAQ;IACjB,OAAO,EAAE,QAAQ;CAClB,CAAC,CAAC;AAGH,MAAM,CAAC,MAAM,sCAAsC,GAAG,IAAI,CAAC;IACzD,WAAW,EAAE,QAAQ;IACrB,SAAS,EAAE,aAAa;IACxB,KAAK,EAAE,SAAS;IAChB,MAAM,EAAE;QACN,MAAM,EAAE,wBAAwB;QAChC,OAAO,EAAE,wBAAwB;KAClC;CACF,CAAC,CAAC;AAIH,MAAM,CAAC,MAAM,iCAAiC,GAAG,IAAI,CAAC;IACpD,EAAE,EAAE,SAAS;IACb,KAAK,EAAE,yEAAyE;IAChF,UAAU,EAAE,kBAAkB;IAC9B,MAAM,EAAE,QAAQ;IAChB,OAAO,EAAE,SAAS;IAClB,iBAAiB,EAAE,UAAU;CAC9B,CAAC,CAAC"}
|
||||
@@ -7,5 +7,6 @@ export * from "./openclawContract.js";
|
||||
export * from "./packages.js";
|
||||
export * from "./pluginCategories.js";
|
||||
export { ApiRoutes, LegacyApiRoutes } from "./routes.js";
|
||||
export * from "./security.js";
|
||||
export * from "./schemas.js";
|
||||
export * from "./textFiles.js";
|
||||
|
||||
@@ -24,6 +24,7 @@ export const ApiRoutes = {
|
||||
stars: "/api/v1/stars",
|
||||
transfers: "/api/v1/transfers",
|
||||
souls: "/api/v1/souls",
|
||||
security: "/api/v1/security",
|
||||
users: "/api/v1/users",
|
||||
whoami: "/api/v1/whoami",
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { type inferred, type } from "arktype";
|
||||
|
||||
export const SecurityScanStatusSchema = type(
|
||||
'"benign"|"suspicious"|"malicious"|"pending"|"unknown"',
|
||||
);
|
||||
export type SecurityScanStatus = (typeof SecurityScanStatusSchema)[inferred];
|
||||
|
||||
export const SecurityScanCountsSchema = type({
|
||||
benign: "number",
|
||||
suspicious: "number",
|
||||
malicious: "number",
|
||||
pending: "number",
|
||||
unknown: "number",
|
||||
});
|
||||
export type SecurityScanCounts = (typeof SecurityScanCountsSchema)[inferred];
|
||||
|
||||
export const ApiV1SecurityScanSummaryResponseSchema = type({
|
||||
generatedAt: "number",
|
||||
updatedAt: "number|null",
|
||||
stale: "boolean",
|
||||
totals: {
|
||||
skills: SecurityScanCountsSchema,
|
||||
plugins: SecurityScanCountsSchema,
|
||||
},
|
||||
});
|
||||
export type ApiV1SecurityScanSummaryResponse =
|
||||
(typeof ApiV1SecurityScanSummaryResponseSchema)[inferred];
|
||||
|
||||
export const ApiV1SecurityRescanResponseSchema = type({
|
||||
ok: "boolean",
|
||||
state: '"queued"|"already_in_progress"|"target_not_found"|"scanner_unavailable"',
|
||||
entityType: '"skill"|"plugin"',
|
||||
target: "string",
|
||||
version: "string?",
|
||||
scheduledScanners: "string[]",
|
||||
});
|
||||
export type ApiV1SecurityRescanResponse = (typeof ApiV1SecurityRescanResponseSchema)[inferred];
|
||||
@@ -125,6 +125,14 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic
|
||||
downgrade them.
|
||||
- Operators can schedule targeted ClawScan rescans for suspicious skills by bucket
|
||||
(`all`, `llm-only`, `vt-only`, `both`) and for suspicious plugin releases.
|
||||
- Staff security scan dashboards and CLI summaries read from the
|
||||
`securityScanRollups` read model, not live full-table aggregate scans. Scanner
|
||||
write paths must keep `securityScanEntityStates` and rollups in sync, and
|
||||
backfills must use cursor pagination instead of whole-table collection.
|
||||
- Staff-triggered security rescans schedule the existing static, ClawScan/LLM,
|
||||
and VirusTotal scanners when the target artifact has a hash suitable for VT.
|
||||
Requests must be staff-only, audit logged, and deduplicated while a matching
|
||||
queued request is still active.
|
||||
- Package/plugin scan backfills now also recompute deterministic static scan results for older releases,
|
||||
so legacy plugin versions can surface OpenClaw scan findings without republishing.
|
||||
- ClawPack package releases keep static/LLM scan inputs intentionally metadata-only for now:
|
||||
|
||||
+217
-1
@@ -1,5 +1,5 @@
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { useMutation, usePaginatedQuery, useQuery } from "convex/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
@@ -92,6 +92,32 @@ type PluginByNameResult = {
|
||||
highlighted: { byUserId: Id<"users">; at: number } | null;
|
||||
} | null;
|
||||
|
||||
type SecurityScanCounts = {
|
||||
benign: number;
|
||||
suspicious: number;
|
||||
malicious: number;
|
||||
pending: number;
|
||||
unknown: number;
|
||||
};
|
||||
|
||||
type SecurityScanItem = {
|
||||
entityType: "skill" | "plugin";
|
||||
targetId: string;
|
||||
label: string;
|
||||
status: "suspicious" | "malicious";
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
type SecurityScanSummary = {
|
||||
generatedAt: number;
|
||||
updatedAt: number | null;
|
||||
stale: boolean;
|
||||
totals: {
|
||||
skills: SecurityScanCounts;
|
||||
plugins: SecurityScanCounts;
|
||||
};
|
||||
};
|
||||
|
||||
function resolveOwnerParam(
|
||||
handle: string | null | undefined,
|
||||
ownerId?: Id<"users"> | Id<"publishers">,
|
||||
@@ -149,6 +175,30 @@ function Management() {
|
||||
api.skills.listDuplicateCandidates,
|
||||
staff ? { limit: 20 } : "skip",
|
||||
) as DuplicateCandidateEntry[] | undefined;
|
||||
const securitySummary = useQuery(
|
||||
api.securityScans.getSecurityScanSummaryForStaff,
|
||||
staff ? {} : "skip",
|
||||
) as SecurityScanSummary | undefined;
|
||||
const { results: suspiciousSkillRows, status: suspiciousSkillRowsStatus } = usePaginatedQuery(
|
||||
api.securityScans.listSecurityScanItemsForStaff,
|
||||
staff ? { entityType: "skill", status: "suspicious" } : "skip",
|
||||
{ initialNumItems: 5 },
|
||||
);
|
||||
const { results: maliciousSkillRows, status: maliciousSkillRowsStatus } = usePaginatedQuery(
|
||||
api.securityScans.listSecurityScanItemsForStaff,
|
||||
staff ? { entityType: "skill", status: "malicious" } : "skip",
|
||||
{ initialNumItems: 5 },
|
||||
);
|
||||
const { results: suspiciousPluginRows, status: suspiciousPluginRowsStatus } = usePaginatedQuery(
|
||||
api.securityScans.listSecurityScanItemsForStaff,
|
||||
staff ? { entityType: "plugin", status: "suspicious" } : "skip",
|
||||
{ initialNumItems: 5 },
|
||||
);
|
||||
const { results: maliciousPluginRows, status: maliciousPluginRowsStatus } = usePaginatedQuery(
|
||||
api.securityScans.listSecurityScanItemsForStaff,
|
||||
staff ? { entityType: "plugin", status: "malicious" } : "skip",
|
||||
{ initialNumItems: 5 },
|
||||
);
|
||||
|
||||
const setRole = useMutation(api.users.setRole);
|
||||
const banUser = useMutation(api.users.banUser);
|
||||
@@ -163,6 +213,12 @@ function Management() {
|
||||
const setDeprecatedBadge = useMutation(api.skills.setDeprecatedBadge);
|
||||
const setSkillManualOverride = useMutation(api.skills.setSkillManualOverride);
|
||||
const clearSkillManualOverride = useMutation(api.skills.clearSkillManualOverride);
|
||||
const requestSkillSecurityRescan = useMutation(
|
||||
api.securityScans.requestSkillSecurityRescanForStaff,
|
||||
);
|
||||
const requestPluginSecurityRescan = useMutation(
|
||||
api.securityScans.requestPluginSecurityRescanForStaff,
|
||||
);
|
||||
|
||||
const [selectedDuplicate, setSelectedDuplicate] = useState("");
|
||||
const [selectedOwner, setSelectedOwner] = useState("");
|
||||
@@ -172,6 +228,7 @@ function Management() {
|
||||
const [userSearchDebounced, setUserSearchDebounced] = useState("");
|
||||
const [pluginSearch, setPluginSearch] = useState(selectedPluginName ?? "");
|
||||
const [skillOverrideNote, setSkillOverrideNote] = useState("");
|
||||
const [securityRescanMessage, setSecurityRescanMessage] = useState("");
|
||||
|
||||
const userQuery = userSearchDebounced.trim();
|
||||
const userResult = useQuery(
|
||||
@@ -262,6 +319,18 @@ function Management() {
|
||||
: "No users yet."
|
||||
: ""
|
||||
: "Loading users…";
|
||||
const securityProblemRows = [
|
||||
...((maliciousSkillRows ?? []) as SecurityScanItem[]),
|
||||
...((suspiciousSkillRows ?? []) as SecurityScanItem[]),
|
||||
...((maliciousPluginRows ?? []) as SecurityScanItem[]),
|
||||
...((suspiciousPluginRows ?? []) as SecurityScanItem[]),
|
||||
].slice(0, 12);
|
||||
const securityProblemRowsLoading = [
|
||||
maliciousSkillRowsStatus,
|
||||
suspiciousSkillRowsStatus,
|
||||
maliciousPluginRowsStatus,
|
||||
suspiciousPluginRowsStatus,
|
||||
].some((status) => status === "LoadingFirstPage");
|
||||
|
||||
const applySkillOverride = () => {
|
||||
if (!selectedSkill?.skill) return;
|
||||
@@ -296,11 +365,100 @@ function Management() {
|
||||
});
|
||||
};
|
||||
|
||||
const requestSelectedSkillRescan = (slug: string) => {
|
||||
setSecurityRescanMessage("");
|
||||
void requestSkillSecurityRescan({ slug })
|
||||
.then((result) => {
|
||||
setSecurityRescanMessage(formatSecurityRescanResult(result));
|
||||
})
|
||||
.catch((error) => window.alert(formatMutationError(error)));
|
||||
};
|
||||
|
||||
const requestSelectedPluginRescan = (name: string, version?: string) => {
|
||||
setSecurityRescanMessage("");
|
||||
void requestPluginSecurityRescan({ name, version })
|
||||
.then((result) => {
|
||||
setSecurityRescanMessage(formatSecurityRescanResult(result));
|
||||
})
|
||||
.catch((error) => window.alert(formatMutationError(error)));
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<h1 className="section-title">Management console</h1>
|
||||
<p className="section-subtitle">Moderation, curation, and ownership tools.</p>
|
||||
|
||||
<Card>
|
||||
<h2 className="section-title text-[1.2rem] m-0">Security scans</h2>
|
||||
{securitySummary ? (
|
||||
<>
|
||||
<div className="management-security-grid">
|
||||
<SecurityScanCountGroup label="Skills" counts={securitySummary.totals.skills} />
|
||||
<SecurityScanCountGroup label="Plugins" counts={securitySummary.totals.plugins} />
|
||||
</div>
|
||||
<div className="section-subtitle mt-2">
|
||||
{securitySummary.updatedAt
|
||||
? `Updated ${formatTimestamp(securitySummary.updatedAt)}`
|
||||
: "Rollups have not been built yet."}
|
||||
{securitySummary.stale ? " Rollups need rebuild before counts are complete." : ""}
|
||||
{securityRescanMessage ? ` ${securityRescanMessage}` : ""}
|
||||
</div>
|
||||
<div className="management-security-problems">
|
||||
<div className="management-report-meta">Problem rows</div>
|
||||
{securityProblemRowsLoading ? (
|
||||
<div className="stat">Loading scan rows…</div>
|
||||
) : securityProblemRows.length > 0 ? (
|
||||
securityProblemRows.map((row) => (
|
||||
<div
|
||||
className="management-security-row"
|
||||
key={`${row.entityType}:${row.targetId}:${row.status}`}
|
||||
>
|
||||
<div>
|
||||
<strong>{row.label}</strong>
|
||||
<div className="section-subtitle">
|
||||
{row.entityType} · {row.status} · {formatTimestamp(row.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="management-actions compact">
|
||||
<Button
|
||||
className="management-action-btn"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
row.entityType === "skill"
|
||||
? requestSelectedSkillRescan(row.label)
|
||||
: requestSelectedPluginRescan(row.label)
|
||||
}
|
||||
>
|
||||
Rescan
|
||||
</Button>
|
||||
<Button
|
||||
className="management-action-btn"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void navigate({
|
||||
to: "/management",
|
||||
search:
|
||||
row.entityType === "skill"
|
||||
? { skill: row.label, plugin: undefined }
|
||||
: { skill: undefined, plugin: row.label },
|
||||
})
|
||||
}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="stat">No suspicious or malicious scan rows.</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="stat">Loading security scan summary…</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="section-title text-[1.2rem] m-0">Reported skills</h2>
|
||||
<div className="management-controls">
|
||||
@@ -620,6 +778,14 @@ function Management() {
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
className="management-action-btn"
|
||||
type="button"
|
||||
disabled={!latestVersion}
|
||||
onClick={() => requestSelectedSkillRescan(skill.slug)}
|
||||
>
|
||||
Rescan
|
||||
</Button>
|
||||
<Button
|
||||
className="management-action-btn"
|
||||
type="button"
|
||||
@@ -803,6 +969,16 @@ function Management() {
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
className="management-action-btn"
|
||||
type="button"
|
||||
disabled={!latestRelease}
|
||||
onClick={() =>
|
||||
requestSelectedPluginRescan(plugin.name, latestRelease?.version)
|
||||
}
|
||||
>
|
||||
Rescan
|
||||
</Button>
|
||||
<Button
|
||||
className="management-action-btn"
|
||||
type="button"
|
||||
@@ -1068,6 +1244,46 @@ function formatMutationError(error: unknown) {
|
||||
return getUserFacingConvexError(error, "Request failed.");
|
||||
}
|
||||
|
||||
function SecurityScanCountGroup({ label, counts }: { label: string; counts: SecurityScanCounts }) {
|
||||
return (
|
||||
<div className="management-security-group">
|
||||
<div className="management-report-meta">{label}</div>
|
||||
<div className="management-security-counts">
|
||||
<SecurityScanCount label="Benign" value={counts.benign} />
|
||||
<SecurityScanCount label="Suspicious" value={counts.suspicious} />
|
||||
<SecurityScanCount label="Malicious" value={counts.malicious} />
|
||||
<SecurityScanCount label="Pending" value={counts.pending} />
|
||||
<SecurityScanCount label="Unknown" value={counts.unknown} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SecurityScanCount({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="management-security-count">
|
||||
<span>{label}</span>
|
||||
<strong>{value.toLocaleString()}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatSecurityRescanResult(result: {
|
||||
state: string;
|
||||
target: string;
|
||||
version?: string;
|
||||
scheduledScanners: string[];
|
||||
}) {
|
||||
const version = result.version ? `@${result.version}` : "";
|
||||
if (result.state === "queued") {
|
||||
return `Queued ${result.scheduledScanners.join(", ")} rescan for ${result.target}${version}.`;
|
||||
}
|
||||
if (result.state === "already_in_progress") {
|
||||
return `Rescan already in progress for ${result.target}${version}.`;
|
||||
}
|
||||
return `Rescan not queued for ${result.target}: ${result.state}.`;
|
||||
}
|
||||
|
||||
function formatManualOverrideState(
|
||||
override:
|
||||
| {
|
||||
|
||||
@@ -7362,6 +7362,74 @@ code {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.management-security-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.management-security-group {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.management-security-counts {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(104px, 1fr));
|
||||
}
|
||||
|
||||
.management-security-count {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
background: color-mix(in srgb, var(--surface) 78%, transparent);
|
||||
}
|
||||
|
||||
.management-security-count span {
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.management-security-count strong {
|
||||
color: var(--ink);
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.management-security-problems {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.management-security-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
background: color-mix(in srgb, var(--surface) 84%, transparent);
|
||||
}
|
||||
|
||||
.management-actions.compact {
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.management-actions.compact .management-action-btn {
|
||||
min-height: 34px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.management-sublist {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
Reference in New Issue
Block a user