feat: add security scan admin tooling

This commit is contained in:
Patrick Erichsen
2026-05-18 14:22:58 -07:00
parent 7cf16ebb28
commit 8924ce6897
34 changed files with 2130 additions and 12 deletions
+6
View File
@@ -32,6 +32,7 @@ import type * as httpApi from "../httpApi.js";
import type * as httpApiV1 from "../httpApiV1.js";
import type * as httpApiV1_docsSessionV1 from "../httpApiV1/docsSessionV1.js";
import type * as httpApiV1_packagesV1 from "../httpApiV1/packagesV1.js";
import type * as httpApiV1_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;
+14
View File
@@ -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",
+169
View File
@@ -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>) => {
+5
View File
@@ -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,
+132
View File
@@ -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);
}
}
+7 -2
View File
@@ -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);
+73
View File
@@ -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 },
]);
});
});
+155
View File
@@ -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
View File
@@ -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++;
}
+51
View File
@@ -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,
+553
View File
@@ -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
View File
@@ -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 =
+4 -1
View File
@@ -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]
+43
View File
@@ -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}` : "";
}
+1
View File
@@ -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";
+1
View File
@@ -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;
+37
View File
@@ -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];
+1
View File
@@ -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";
+1
View File
@@ -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
+1 -1
View File
@@ -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"}
+1
View File
@@ -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";
};
+1
View File
@@ -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",
};
+1 -1
View File
@@ -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"}
+42
View File
@@ -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];
+27
View File
@@ -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
+1
View File
@@ -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"}
+1
View File
@@ -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";
+1
View File
@@ -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;
+37
View File
@@ -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];
+8
View File
@@ -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
View File
@@ -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:
| {
+68
View File
@@ -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;