mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b30512f110 | ||
|
|
5843dfecd3 | ||
|
|
8936294dec | ||
|
|
fbecc30e70 | ||
|
|
2f7c2d411a | ||
|
|
70692264ed |
@@ -0,0 +1,95 @@
|
||||
---
|
||||
name: security-scan-overview
|
||||
description: Use when ClawHub staff or agents need production security scan health, ClawScan verdict/category rollups, failed or queued/running scan triage, or per-skill/per-plugin scanner drilldown through clawhub-mod security-scans commands and Convex/API staff surfaces.
|
||||
---
|
||||
|
||||
# Security Scan Overview
|
||||
|
||||
Use the repo-local `clawhub-mod` CLI from a checked-out ClawHub repo. Treat
|
||||
ClawScan/Codex verdict and category fields as the source of truth. Treat
|
||||
SkillSpector, static scan, VirusTotal, and worker details as supporting
|
||||
evidence for drilldown and diagnosis.
|
||||
|
||||
## Quick Checks
|
||||
|
||||
Validate the token and target registry first:
|
||||
|
||||
```sh
|
||||
bun run mod -- whoami
|
||||
```
|
||||
|
||||
For production, the default registry is `https://clawhub.ai`. For local or
|
||||
staging proof, pass the exact API base:
|
||||
|
||||
```sh
|
||||
bun run mod -- --registry <convex-http-url> whoami
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
Overview for skills and plugins:
|
||||
|
||||
```sh
|
||||
bun run mod -- security-scans overview --window-hours 24
|
||||
bun run mod -- security-scans overview --window-hours 24 --json
|
||||
```
|
||||
|
||||
Current breakdowns by artifact kind or ClawScan verdict/category:
|
||||
|
||||
```sh
|
||||
bun run mod -- security-scans overview --artifact-kind skill
|
||||
bun run mod -- security-scans list --artifact-kind plugin --verdict malicious --json
|
||||
bun run mod -- security-scans list --artifact-kind skill --category <clawscan-category-key>
|
||||
```
|
||||
|
||||
Pipeline health:
|
||||
|
||||
```sh
|
||||
bun run mod -- security-scans failed --artifact-kind all --limit 25 --json
|
||||
bun run mod -- security-scans queued --artifact-kind all --limit 25
|
||||
bun run mod -- security-scans running --artifact-kind all --limit 25
|
||||
```
|
||||
|
||||
Artifact drilldown:
|
||||
|
||||
```sh
|
||||
bun run mod -- security-scans inspect --skill <slug> --json
|
||||
bun run mod -- security-scans inspect --plugin <package-name> --json
|
||||
```
|
||||
|
||||
Use `--artifact-kind skill` or `--artifact-kind plugin` with `--cursor` when
|
||||
paginating. The combined `all` view is a first-page operator summary and should
|
||||
not be used as a cursor stream.
|
||||
|
||||
## Reporting
|
||||
|
||||
Report ClawScan first:
|
||||
|
||||
- Current verdict totals as `X/Y (Z%)` for pass, suspicious, malicious, pending,
|
||||
failed, and unknown.
|
||||
- ClawScan category rows as category label/key, artifact kind, verdict, and
|
||||
count/percentage.
|
||||
- Last-window health: scan events, queued, running, succeeded, and failed.
|
||||
- Failed samples with artifact kind, slug/package, version, ClawScan verdict,
|
||||
job status, error, and updated time.
|
||||
|
||||
For one artifact, summarize:
|
||||
|
||||
1. ClawScan verdict/status/category/summary.
|
||||
2. Worker status, attempts, queue/start/finish/failure times, and last error.
|
||||
3. SkillSpector score/severity/category as evidence only.
|
||||
4. Static scan and VirusTotal results as supporting signals.
|
||||
|
||||
## Scale And Safety
|
||||
|
||||
- Prefer `overview` before `list`; it reads digest rollups instead of paging
|
||||
artifact rows.
|
||||
- Keep `list` limits bounded. Start with 25, increase only when needed, and use
|
||||
cursor pagination for skill/plugin-specific streams.
|
||||
- Do not scrape the management UI for data; use `clawhub-mod` or the
|
||||
corresponding `/api/v1/security-scans/*` staff endpoints.
|
||||
- These commands are read-only. Do not queue rescans or mutate moderation state
|
||||
unless the user explicitly asks for that separate action.
|
||||
- If a result seems stale, say which registry was queried and check whether
|
||||
digest backfills or scan workers are currently queued/running before drawing a
|
||||
production conclusion.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Security Scan Overview"
|
||||
short_description: "Summarize ClawHub scan health."
|
||||
default_prompt: "Use $security-scan-overview to summarize ClawHub security scan health with ClawScan as the verdict source of truth."
|
||||
@@ -51,6 +51,8 @@ skills-lock.json
|
||||
!.agents/skills/clawhub-moderation/**
|
||||
!.agents/skills/autoreview/
|
||||
!.agents/skills/autoreview/**
|
||||
!.agents/skills/security-scan-overview/
|
||||
!.agents/skills/security-scan-overview/**
|
||||
skills/*
|
||||
.codex/*
|
||||
!.codex/environments/
|
||||
|
||||
Vendored
+6
@@ -33,6 +33,7 @@ import type * as httpApiV1 from "../httpApiV1.js";
|
||||
import type * as httpApiV1_docsSessionV1 from "../httpApiV1/docsSessionV1.js";
|
||||
import type * as httpApiV1_packagesV1 from "../httpApiV1/packagesV1.js";
|
||||
import type * as httpApiV1_publishersV1 from "../httpApiV1/publishersV1.js";
|
||||
import type * as httpApiV1_securityScansV1 from "../httpApiV1/securityScansV1.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";
|
||||
@@ -89,6 +90,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_securityScanDigest from "../lib/securityScanDigest.js";
|
||||
import type * as lib_skillBackfill from "../lib/skillBackfill.js";
|
||||
import type * as lib_skillCapabilityTags from "../lib/skillCapabilityTags.js";
|
||||
import type * as lib_skillCards from "../lib/skillCards.js";
|
||||
@@ -119,6 +121,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 securityScanDigests from "../securityScanDigests.js";
|
||||
import type * as seed from "../seed.js";
|
||||
import type * as seedSouls from "../seedSouls.js";
|
||||
import type * as skillCards from "../skillCards.js";
|
||||
@@ -170,6 +173,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"httpApiV1/docsSessionV1": typeof httpApiV1_docsSessionV1;
|
||||
"httpApiV1/packagesV1": typeof httpApiV1_packagesV1;
|
||||
"httpApiV1/publishersV1": typeof httpApiV1_publishersV1;
|
||||
"httpApiV1/securityScansV1": typeof httpApiV1_securityScansV1;
|
||||
"httpApiV1/shared": typeof httpApiV1_shared;
|
||||
"httpApiV1/skillsV1": typeof httpApiV1_skillsV1;
|
||||
"httpApiV1/soulsV1": typeof httpApiV1_soulsV1;
|
||||
@@ -226,6 +230,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/reservedSlugs": typeof lib_reservedSlugs;
|
||||
"lib/searchText": typeof lib_searchText;
|
||||
"lib/securityPrompt": typeof lib_securityPrompt;
|
||||
"lib/securityScanDigest": typeof lib_securityScanDigest;
|
||||
"lib/skillBackfill": typeof lib_skillBackfill;
|
||||
"lib/skillCapabilityTags": typeof lib_skillCapabilityTags;
|
||||
"lib/skillCards": typeof lib_skillCards;
|
||||
@@ -256,6 +261,7 @@ declare const fullApi: ApiFromModules<{
|
||||
securityDataset: typeof securityDataset;
|
||||
securityDatasetNode: typeof securityDatasetNode;
|
||||
securityScan: typeof securityScan;
|
||||
securityScanDigests: typeof securityScanDigests;
|
||||
seed: typeof seed;
|
||||
seedSouls: typeof seedSouls;
|
||||
skillCards: typeof skillCards;
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
publishSoulV1Http,
|
||||
resolveSkillVersionV1Http,
|
||||
searchSkillsV1Http,
|
||||
securityScansGetRouterV1Http,
|
||||
skillsDeleteRouterV1Http,
|
||||
skillsGetRouterV1Http,
|
||||
skillsPostRouterV1Http,
|
||||
@@ -199,6 +200,12 @@ http.route({
|
||||
handler: createPublisherV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
pathPrefix: `${ApiRoutes.securityScans}/`,
|
||||
method: "GET",
|
||||
handler: securityScansGetRouterV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
path: ApiRoutes.whoami,
|
||||
method: "GET",
|
||||
|
||||
@@ -262,6 +262,120 @@ describe("httpApiV1 handlers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("security-scans forbids non-moderator api tokens", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:viewer",
|
||||
user: { _id: "users:viewer", role: "user" },
|
||||
} as never);
|
||||
const runQuery = vi.fn();
|
||||
const ctx = makeCtx({ runQuery });
|
||||
|
||||
const response = await __handlers.securityScansGetRouterV1Handler(
|
||||
ctx,
|
||||
new Request("https://example.com/api/v1/security-scans/overview"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(runQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("security-scans overview passes moderator token actor through to the digest query", async () => {
|
||||
const overview = {
|
||||
generatedAt: 1,
|
||||
window: { hours: 24, totalsByKind: {}, rows: [], truncated: false },
|
||||
current: {},
|
||||
failed: { items: [], limit: 2 },
|
||||
};
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:mod",
|
||||
user: { _id: "users:mod", role: "moderator" },
|
||||
} as never);
|
||||
const runQuery = vi.fn().mockResolvedValue(overview);
|
||||
const ctx = makeCtx({
|
||||
runQuery,
|
||||
});
|
||||
|
||||
const response = await __handlers.securityScansGetRouterV1Handler(
|
||||
ctx,
|
||||
new Request(
|
||||
"https://example.com/api/v1/security-scans/overview?artifactKind=skill&windowHours=48&failedLimit=2",
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual(overview);
|
||||
expect(runQuery).toHaveBeenCalledWith(expect.anything(), {
|
||||
actorUserId: "users:mod",
|
||||
artifactKind: "skill",
|
||||
windowHours: 48,
|
||||
failedLimit: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("security-scans artifacts parses filters and pagination", async () => {
|
||||
const page = { items: [], nextCursor: "next", done: false, limit: 5 };
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
user: { _id: "users:admin", role: "admin" },
|
||||
} as never);
|
||||
const runQuery = vi.fn().mockResolvedValue(page);
|
||||
const ctx = makeCtx({
|
||||
runQuery,
|
||||
});
|
||||
|
||||
const response = await __handlers.securityScansGetRouterV1Handler(
|
||||
ctx,
|
||||
new Request(
|
||||
"https://example.com/api/v1/security-scans/artifacts?artifactKind=plugin&scanJobStatus=queued&limit=5&cursor=c1",
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual(page);
|
||||
expect(runQuery).toHaveBeenCalledWith(expect.anything(), {
|
||||
actorUserId: "users:admin",
|
||||
artifactKind: "plugin",
|
||||
cursor: "c1",
|
||||
limit: 5,
|
||||
clawScanVerdict: undefined,
|
||||
scanJobStatus: "queued",
|
||||
failureStatus: undefined,
|
||||
clawScanPrimaryCategoryKey: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("security-scans artifact inspect accepts a skill slug", async () => {
|
||||
const detail = {
|
||||
found: true,
|
||||
artifactKind: "skill",
|
||||
state: null,
|
||||
artifact: {},
|
||||
scanJob: null,
|
||||
evidence: {},
|
||||
};
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:mod",
|
||||
user: { _id: "users:mod", role: "moderator" },
|
||||
} as never);
|
||||
const runQuery = vi.fn().mockResolvedValue(detail);
|
||||
const ctx = makeCtx({
|
||||
runQuery,
|
||||
});
|
||||
|
||||
const response = await __handlers.securityScansGetRouterV1Handler(
|
||||
ctx,
|
||||
new Request("https://example.com/api/v1/security-scans/artifact?skillSlug=demo"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual(detail);
|
||||
expect(runQuery).toHaveBeenCalledWith(expect.anything(), {
|
||||
actorUserId: "users:mod",
|
||||
skillSlug: "demo",
|
||||
packageName: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("skills export allows authenticated non-admin users at the key rate limit", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:actor",
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
publishPackageV1Handler,
|
||||
} from "./httpApiV1/packagesV1";
|
||||
import { createPublisherV1Handler } from "./httpApiV1/publishersV1";
|
||||
import { securityScansGetRouterV1Handler } from "./httpApiV1/securityScansV1";
|
||||
import {
|
||||
exportSkillsV1Handler,
|
||||
listSkillsV1Handler,
|
||||
@@ -49,6 +50,7 @@ export const listCodePluginsV1Http = httpAction(listCodePluginsV1Handler);
|
||||
export const listBundlePluginsV1Http = httpAction(listBundlePluginsV1Handler);
|
||||
export const verifyDocsSessionV1Http = httpAction(verifyDocsSessionV1Handler);
|
||||
export const createPublisherV1Http = httpAction(createPublisherV1Handler);
|
||||
export const securityScansGetRouterV1Http = httpAction(securityScansGetRouterV1Handler);
|
||||
|
||||
export const searchSkillsV1Http = httpAction(searchSkillsV1Handler);
|
||||
export const resolveSkillVersionV1Http = httpAction(resolveSkillVersionV1Handler);
|
||||
@@ -87,6 +89,7 @@ export const __handlers = {
|
||||
listBundlePluginsV1Handler,
|
||||
verifyDocsSessionV1Handler,
|
||||
createPublisherV1Handler,
|
||||
securityScansGetRouterV1Handler,
|
||||
searchSkillsV1Handler,
|
||||
resolveSkillVersionV1Handler,
|
||||
listSkillsV1Handler,
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { internal } from "../_generated/api";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { ActionCtx } from "../_generated/server";
|
||||
import { assertModerator } from "../lib/access";
|
||||
import { applyRateLimit } from "../lib/httpRateLimit";
|
||||
import {
|
||||
CLAW_SCAN_DIGEST_VERDICTS,
|
||||
SECURITY_SCAN_FAILURE_STATUSES,
|
||||
SECURITY_SCAN_PIPELINE_STATUSES,
|
||||
} from "../lib/securityScanDigest";
|
||||
import {
|
||||
getPathSegments,
|
||||
json,
|
||||
requireApiTokenUserOrResponse,
|
||||
text,
|
||||
toOptionalNumber,
|
||||
} from "./shared";
|
||||
|
||||
const securityScanInternalRefs = internal as unknown as {
|
||||
securityScanDigests: {
|
||||
getStaffSecurityScanOverviewInternal: unknown;
|
||||
listStaffSecurityScanArtifactsInternal: unknown;
|
||||
getStaffSecurityScanArtifactInternal: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
async function runSecurityScanQueryRef<T>(
|
||||
ctx: Pick<ActionCtx, "runQuery">,
|
||||
ref: unknown,
|
||||
args: unknown,
|
||||
): Promise<T> {
|
||||
return (await ctx.runQuery(ref as never, args as never)) as T;
|
||||
}
|
||||
|
||||
function requireModeratorOrResponse(user: Doc<"users">, headers: HeadersInit) {
|
||||
try {
|
||||
assertModerator(user);
|
||||
return { ok: true as const };
|
||||
} catch {
|
||||
return { ok: false as const, response: text("Moderator role required.", 403, headers) };
|
||||
}
|
||||
}
|
||||
|
||||
function toOptionalArtifactKind(value: string | null) {
|
||||
if (!value) return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "skill" || normalized === "plugin") return normalized;
|
||||
return null;
|
||||
}
|
||||
|
||||
function toOptionalClawScanVerdict(value: string | null) {
|
||||
if (!value) return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return CLAW_SCAN_DIGEST_VERDICTS.includes(normalized as never) ? normalized : null;
|
||||
}
|
||||
|
||||
function toOptionalScanJobStatus(value: string | null) {
|
||||
if (!value) return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return SECURITY_SCAN_PIPELINE_STATUSES.includes(normalized as never) ? normalized : null;
|
||||
}
|
||||
|
||||
function toOptionalFailureStatus(value: string | null) {
|
||||
if (!value) return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return SECURITY_SCAN_FAILURE_STATUSES.includes(normalized as never) ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: string | null) {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
function mapSecurityScanReadError(error: unknown, headers: HeadersInit) {
|
||||
const message = error instanceof Error ? error.message : "Security scan read failed";
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes("forbidden") || lower.includes("moderator")) {
|
||||
return text("Forbidden", 403, headers);
|
||||
}
|
||||
if (lower.includes("not found")) {
|
||||
return text(message, 404, headers);
|
||||
}
|
||||
return text(message, 400, headers);
|
||||
}
|
||||
|
||||
export async function securityScansGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, "read");
|
||||
if (!rate.ok) return rate.response;
|
||||
|
||||
const segments = getPathSegments(request, "/api/v1/security-scans/");
|
||||
if (segments.length !== 1) return text("Not found", 404, rate.headers);
|
||||
|
||||
const action = segments[0];
|
||||
if (action !== "overview" && action !== "artifacts" && action !== "artifact") {
|
||||
return text("Not found", 404, rate.headers);
|
||||
}
|
||||
|
||||
const authResult = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
|
||||
if (!authResult.ok) return authResult.response;
|
||||
const moderator = requireModeratorOrResponse(authResult.user, rate.headers);
|
||||
if (!moderator.ok) return moderator.response;
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const actorUserId = authResult.userId as Id<"users">;
|
||||
|
||||
try {
|
||||
if (action === "overview") {
|
||||
const artifactKind = toOptionalArtifactKind(params.get("artifactKind"));
|
||||
if (artifactKind === null) return text("Invalid artifactKind", 400, rate.headers);
|
||||
const result = await runSecurityScanQueryRef(
|
||||
ctx,
|
||||
securityScanInternalRefs.securityScanDigests.getStaffSecurityScanOverviewInternal,
|
||||
{
|
||||
actorUserId,
|
||||
artifactKind,
|
||||
windowHours: toOptionalNumber(params.get("windowHours")),
|
||||
failedLimit: toOptionalNumber(params.get("failedLimit")),
|
||||
},
|
||||
);
|
||||
return json(result, 200, rate.headers);
|
||||
}
|
||||
|
||||
if (action === "artifacts") {
|
||||
const artifactKind = toOptionalArtifactKind(params.get("artifactKind"));
|
||||
if (artifactKind === undefined) return text("Missing artifactKind", 400, rate.headers);
|
||||
if (artifactKind === null) return text("Invalid artifactKind", 400, rate.headers);
|
||||
|
||||
const clawScanVerdict = toOptionalClawScanVerdict(params.get("clawScanVerdict"));
|
||||
if (clawScanVerdict === null) return text("Invalid clawScanVerdict", 400, rate.headers);
|
||||
const scanJobStatus = toOptionalScanJobStatus(params.get("scanJobStatus"));
|
||||
if (scanJobStatus === null) return text("Invalid scanJobStatus", 400, rate.headers);
|
||||
const failureStatus = toOptionalFailureStatus(params.get("failureStatus"));
|
||||
if (failureStatus === null) return text("Invalid failureStatus", 400, rate.headers);
|
||||
|
||||
const result = await runSecurityScanQueryRef(
|
||||
ctx,
|
||||
securityScanInternalRefs.securityScanDigests.listStaffSecurityScanArtifactsInternal,
|
||||
{
|
||||
actorUserId,
|
||||
artifactKind,
|
||||
cursor: normalizeOptionalString(params.get("cursor")) ?? null,
|
||||
limit: toOptionalNumber(params.get("limit")),
|
||||
clawScanVerdict,
|
||||
scanJobStatus,
|
||||
failureStatus,
|
||||
clawScanPrimaryCategoryKey: normalizeOptionalString(
|
||||
params.get("clawScanPrimaryCategoryKey"),
|
||||
),
|
||||
},
|
||||
);
|
||||
return json(result, 200, rate.headers);
|
||||
}
|
||||
|
||||
const result = await runSecurityScanQueryRef(
|
||||
ctx,
|
||||
securityScanInternalRefs.securityScanDigests.getStaffSecurityScanArtifactInternal,
|
||||
{
|
||||
actorUserId,
|
||||
skillSlug: normalizeOptionalString(params.get("skillSlug")),
|
||||
packageName: normalizeOptionalString(params.get("packageName")),
|
||||
},
|
||||
);
|
||||
return json(result, 200, rate.headers);
|
||||
} catch (error) {
|
||||
return mapSecurityScanReadError(error, rate.headers);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildPluginSecurityScanArtifactState,
|
||||
buildSkillSecurityScanArtifactState,
|
||||
clampSecurityScanDigestBackfillBatchSize,
|
||||
clawScanVerdictForState,
|
||||
clawScanVerdictFromLlmAnalysis,
|
||||
getCurrentRollupDeltas,
|
||||
toSecurityScanHourBucket,
|
||||
} from "./securityScanDigest";
|
||||
|
||||
const checkedAt = Date.UTC(2026, 0, 1, 12, 15, 0);
|
||||
|
||||
function makeClawScanAnalysis(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
status: "clean",
|
||||
verdict: "benign",
|
||||
summary: "Looks purpose aligned.",
|
||||
checkedAt,
|
||||
...overrides,
|
||||
} as never;
|
||||
}
|
||||
|
||||
function makeSkill(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
_id: "skills:abc" as never,
|
||||
slug: "demo-skill",
|
||||
displayName: "Demo Skill",
|
||||
ownerUserId: "users:owner" as never,
|
||||
ownerPublisherId: "publishers:owner" as never,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSkillVersion(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
_id: "skillVersions:v1" as never,
|
||||
version: "1.0.0",
|
||||
createdAt: checkedAt - 1_000,
|
||||
vtAnalysis: undefined,
|
||||
skillSpectorAnalysis: undefined,
|
||||
llmAnalysis: makeClawScanAnalysis(),
|
||||
staticScan: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makePackage(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
_id: "packages:plugin" as never,
|
||||
name: "@demo/plugin",
|
||||
displayName: "Demo Plugin",
|
||||
ownerUserId: "users:owner" as never,
|
||||
ownerPublisherId: "publishers:owner" as never,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makePackageRelease(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
_id: "packageReleases:r1" as never,
|
||||
version: "2.0.0",
|
||||
createdAt: checkedAt - 1_000,
|
||||
vtAnalysis: undefined,
|
||||
skillSpectorAnalysis: undefined,
|
||||
llmAnalysis: undefined,
|
||||
staticScan: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeJob(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
_id: "securityScanJobs:j1" as never,
|
||||
status: "queued",
|
||||
source: "publish",
|
||||
createdAt: checkedAt - 500,
|
||||
updatedAt: checkedAt - 250,
|
||||
completedAt: undefined,
|
||||
lastError: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("clawScanVerdictFromLlmAnalysis", () => {
|
||||
it("maps ClawScan/Codex verdicts into dashboard buckets", () => {
|
||||
expect(clawScanVerdictFromLlmAnalysis(makeClawScanAnalysis())).toBe("pass");
|
||||
expect(
|
||||
clawScanVerdictFromLlmAnalysis(
|
||||
makeClawScanAnalysis({ status: "suspicious", verdict: "suspicious" }),
|
||||
),
|
||||
).toBe("suspicious");
|
||||
expect(
|
||||
clawScanVerdictFromLlmAnalysis(
|
||||
makeClawScanAnalysis({ status: "completed", verdict: "malicious" }),
|
||||
),
|
||||
).toBe("malicious");
|
||||
expect(clawScanVerdictFromLlmAnalysis(undefined)).toBe("pending");
|
||||
});
|
||||
|
||||
it("promotes clean ClawScan results with visible medium-or-higher findings to review", () => {
|
||||
expect(
|
||||
clawScanVerdictFromLlmAnalysis(
|
||||
makeClawScanAnalysis({
|
||||
agenticRiskFindings: [
|
||||
{
|
||||
categoryId: "ASI03",
|
||||
categoryLabel: "Identity and Privilege Abuse",
|
||||
riskBucket: "permission_boundary",
|
||||
status: "note",
|
||||
severity: "medium",
|
||||
confidence: "medium",
|
||||
evidence: {
|
||||
path: "metadata",
|
||||
snippet: "requires.env: TODOIST_API_TOKEN",
|
||||
explanation: "Broad account access is material.",
|
||||
},
|
||||
userImpact: "Account-level access.",
|
||||
recommendation: "Review the permission scope.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe("suspicious");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clawScanVerdictForState", () => {
|
||||
it("uses queued/running/failed job state when no ClawScan analysis exists", () => {
|
||||
expect(clawScanVerdictForState({ scanJobStatus: "queued" })).toBe("pending");
|
||||
expect(clawScanVerdictForState({ scanJobStatus: "running" })).toBe("pending");
|
||||
expect(clawScanVerdictForState({ scanJobStatus: "failed" })).toBe("failed");
|
||||
expect(clawScanVerdictForState({ scanJobStatus: "none" })).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("security scan digest builders", () => {
|
||||
it("builds a skill artifact state with ClawScan as the verdict source and scanner evidence", () => {
|
||||
const state = buildSkillSecurityScanArtifactState({
|
||||
skill: makeSkill() as never,
|
||||
version: makeSkillVersion({
|
||||
llmAnalysis: makeClawScanAnalysis({ status: "clean", verdict: "benign" }),
|
||||
skillSpectorAnalysis: {
|
||||
status: "suspicious",
|
||||
score: 85,
|
||||
severity: "HIGH",
|
||||
recommendation: "REVIEW",
|
||||
issueCount: 1,
|
||||
issues: [
|
||||
{
|
||||
issueId: "secret-egress",
|
||||
category: "Prompt Injection",
|
||||
severity: "HIGH",
|
||||
explanation: "Possible injection.",
|
||||
},
|
||||
],
|
||||
checkedAt,
|
||||
},
|
||||
staticScan: {
|
||||
status: "malicious",
|
||||
reasonCodes: ["malicious.external_transfer"],
|
||||
findings: [],
|
||||
summary: "External transfer.",
|
||||
engineVersion: "v1",
|
||||
checkedAt,
|
||||
},
|
||||
}) as never,
|
||||
scanJob: makeJob({ status: "succeeded", completedAt: checkedAt }) as never,
|
||||
now: checkedAt,
|
||||
});
|
||||
|
||||
expect(state.artifactKind).toBe("skill");
|
||||
expect(state.artifactKey).toBe("skill:skills:abc");
|
||||
expect(state.targetKey).toBe("skillVersion:skillVersions:v1");
|
||||
expect(state.clawScanVerdict).toBe("pass");
|
||||
expect(state.scanJobStatus).toBe("succeeded");
|
||||
expect(state.skillSpectorScore).toBe(85);
|
||||
expect(state.skillSpectorTopCategory).toBe("Prompt Injection");
|
||||
expect(state.staticStatus).toBe("malicious");
|
||||
});
|
||||
|
||||
it("builds a plugin artifact state for failed scans without source analysis", () => {
|
||||
const state = buildPluginSecurityScanArtifactState({
|
||||
pkg: makePackage() as never,
|
||||
release: makePackageRelease() as never,
|
||||
scanJob: makeJob({ status: "failed", lastError: "Worker timed out" }) as never,
|
||||
now: checkedAt,
|
||||
});
|
||||
|
||||
expect(state.artifactKind).toBe("plugin");
|
||||
expect(state.name).toBe("@demo/plugin");
|
||||
expect(state.clawScanVerdict).toBe("failed");
|
||||
expect(state.scanJobStatus).toBe("failed");
|
||||
expect(state.failureStatus).toBe("failed");
|
||||
expect(state.lastError).toBe("Worker timed out");
|
||||
});
|
||||
});
|
||||
|
||||
describe("security scan digest rollups", () => {
|
||||
it("computes current rollup deltas when an artifact changes verdict and category", () => {
|
||||
const previous = buildSkillSecurityScanArtifactState({
|
||||
skill: makeSkill() as never,
|
||||
version: makeSkillVersion() as never,
|
||||
now: checkedAt,
|
||||
});
|
||||
const next = buildSkillSecurityScanArtifactState({
|
||||
skill: makeSkill() as never,
|
||||
version: makeSkillVersion({
|
||||
llmAnalysis: makeClawScanAnalysis({
|
||||
status: "malicious",
|
||||
verdict: "malicious",
|
||||
agenticRiskFindings: [
|
||||
{
|
||||
categoryId: "ASI07",
|
||||
categoryLabel: "Insecure Inter-Agent Communication",
|
||||
riskBucket: "sensitive_data_protection",
|
||||
status: "concern",
|
||||
severity: "critical",
|
||||
confidence: "high",
|
||||
evidence: {
|
||||
path: "SKILL.md",
|
||||
snippet: "send secrets",
|
||||
explanation: "Secrets leave the workspace.",
|
||||
},
|
||||
userImpact: "Secret exfiltration.",
|
||||
recommendation: "Remove the transfer.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
}) as never,
|
||||
now: checkedAt + 1,
|
||||
});
|
||||
|
||||
const deltas = getCurrentRollupDeltas(previous, next);
|
||||
|
||||
expect(deltas).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
delta: -1,
|
||||
dimensions: expect.objectContaining({
|
||||
rollupKind: "all",
|
||||
clawScanVerdict: "pass",
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
delta: 1,
|
||||
dimensions: expect.objectContaining({
|
||||
rollupKind: "all",
|
||||
clawScanVerdict: "malicious",
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
delta: 1,
|
||||
dimensions: expect.objectContaining({
|
||||
rollupKind: "clawscanRiskBucket",
|
||||
categoryKey: "sensitive_data_protection",
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
delta: 1,
|
||||
dimensions: expect.objectContaining({
|
||||
rollupKind: "clawscanCategory",
|
||||
categoryKey: "ASI07",
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("computes decrement deltas when an artifact state is pruned", () => {
|
||||
const previous = buildSkillSecurityScanArtifactState({
|
||||
skill: makeSkill() as never,
|
||||
version: makeSkillVersion() as never,
|
||||
now: checkedAt,
|
||||
});
|
||||
|
||||
expect(getCurrentRollupDeltas(previous, null)).toEqual([
|
||||
expect.objectContaining({
|
||||
delta: -1,
|
||||
dimensions: expect.objectContaining({
|
||||
rollupKind: "all",
|
||||
categoryKey: "all",
|
||||
clawScanVerdict: "pass",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("security scan digest backfill helpers", () => {
|
||||
it("rounds timestamps down to hourly buckets", () => {
|
||||
expect(toSecurityScanHourBucket(Date.UTC(2026, 0, 1, 12, 59, 59))).toBe(
|
||||
Date.UTC(2026, 0, 1, 12, 0, 0),
|
||||
);
|
||||
});
|
||||
|
||||
it("clamps page size for cursor-safe backfills", () => {
|
||||
expect(clampSecurityScanDigestBackfillBatchSize(undefined)).toBe(50);
|
||||
expect(clampSecurityScanDigestBackfillBatchSize(0)).toBe(1);
|
||||
expect(clampSecurityScanDigestBackfillBatchSize(500)).toBe(250);
|
||||
expect(clampSecurityScanDigestBackfillBatchSize(12.8)).toBe(12);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,533 @@
|
||||
import type { Doc } from "../_generated/dataModel";
|
||||
|
||||
export const SECURITY_SCAN_ARTIFACT_KINDS = ["skill", "plugin"] as const;
|
||||
export type SecurityScanArtifactKind = (typeof SECURITY_SCAN_ARTIFACT_KINDS)[number];
|
||||
|
||||
export const CLAW_SCAN_DIGEST_VERDICTS = [
|
||||
"pass",
|
||||
"suspicious",
|
||||
"malicious",
|
||||
"pending",
|
||||
"failed",
|
||||
"unknown",
|
||||
] as const;
|
||||
export type ClawScanDigestVerdict = (typeof CLAW_SCAN_DIGEST_VERDICTS)[number];
|
||||
|
||||
export const SECURITY_SCAN_PIPELINE_STATUSES = [
|
||||
"none",
|
||||
"queued",
|
||||
"running",
|
||||
"succeeded",
|
||||
"failed",
|
||||
] as const;
|
||||
export type SecurityScanPipelineStatus = (typeof SECURITY_SCAN_PIPELINE_STATUSES)[number];
|
||||
|
||||
export const SECURITY_SCAN_FAILURE_STATUSES = ["none", "failed"] as const;
|
||||
export type SecurityScanFailureStatus = (typeof SECURITY_SCAN_FAILURE_STATUSES)[number];
|
||||
|
||||
export const SECURITY_SCAN_ROLLUP_KINDS = [
|
||||
"all",
|
||||
"clawscanRiskBucket",
|
||||
"clawscanCategory",
|
||||
] as const;
|
||||
export type SecurityScanRollupKind = (typeof SECURITY_SCAN_ROLLUP_KINDS)[number];
|
||||
|
||||
export type SecurityScanArtifactStateFields = Omit<
|
||||
Doc<"securityScanArtifactStates">,
|
||||
"_creationTime" | "_id"
|
||||
>;
|
||||
|
||||
export type SecurityScanCurrentRollupDimensions = Pick<
|
||||
Doc<"securityScanCurrentRollups">,
|
||||
| "artifactKind"
|
||||
| "rollupKind"
|
||||
| "categoryKey"
|
||||
| "clawScanVerdict"
|
||||
| "scanJobStatus"
|
||||
| "failureStatus"
|
||||
> & {
|
||||
categoryLabel?: string;
|
||||
};
|
||||
|
||||
export type SecurityScanHourlyRollupDimensions = Pick<
|
||||
Doc<"securityScanHourlyRollups">,
|
||||
"artifactKind" | "clawScanVerdict" | "scanJobStatus" | "failureStatus"
|
||||
>;
|
||||
|
||||
export type SecurityScanRollupDelta = {
|
||||
dimensions: SecurityScanCurrentRollupDimensions;
|
||||
delta: 1 | -1;
|
||||
};
|
||||
|
||||
type LlmAnalysisLike = NonNullable<Doc<"skillVersions">["llmAnalysis"]>;
|
||||
type SkillSpectorAnalysisLike = NonNullable<Doc<"skillVersions">["skillSpectorAnalysis"]>;
|
||||
type StaticScanLike = NonNullable<Doc<"skillVersions">["staticScan"]>;
|
||||
type VirusTotalAnalysisLike = NonNullable<Doc<"skillVersions">["vtAnalysis"]>;
|
||||
type SecurityScanJobLike = Pick<
|
||||
Doc<"securityScanJobs">,
|
||||
| "_id"
|
||||
| "status"
|
||||
| "source"
|
||||
| "workerId"
|
||||
| "attempts"
|
||||
| "createdAt"
|
||||
| "updatedAt"
|
||||
| "completedAt"
|
||||
| "lastError"
|
||||
>;
|
||||
|
||||
type SkillDigestInput = Pick<
|
||||
Doc<"skills">,
|
||||
"_id" | "slug" | "displayName" | "ownerUserId" | "ownerPublisherId"
|
||||
>;
|
||||
|
||||
type SkillVersionDigestInput = Pick<
|
||||
Doc<"skillVersions">,
|
||||
| "_id"
|
||||
| "version"
|
||||
| "vtAnalysis"
|
||||
| "skillSpectorAnalysis"
|
||||
| "llmAnalysis"
|
||||
| "staticScan"
|
||||
| "createdAt"
|
||||
>;
|
||||
|
||||
type PackageDigestInput = Pick<
|
||||
Doc<"packages">,
|
||||
"_id" | "name" | "displayName" | "ownerUserId" | "ownerPublisherId"
|
||||
>;
|
||||
|
||||
type PackageReleaseDigestInput = Pick<
|
||||
Doc<"packageReleases">,
|
||||
| "_id"
|
||||
| "version"
|
||||
| "vtAnalysis"
|
||||
| "skillSpectorAnalysis"
|
||||
| "llmAnalysis"
|
||||
| "staticScan"
|
||||
| "createdAt"
|
||||
>;
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const DEFAULT_BACKFILL_BATCH_SIZE = 50;
|
||||
const MAX_BACKFILL_BATCH_SIZE = 250;
|
||||
const MAX_DIGEST_TEXT_LENGTH = 2_000;
|
||||
|
||||
const RISK_BUCKET_LABELS: Record<string, string> = {
|
||||
abnormal_behavior_control: "Abnormal behavior control",
|
||||
permission_boundary: "Permission boundary",
|
||||
sensitive_data_protection: "Sensitive data protection",
|
||||
};
|
||||
|
||||
function normalizeToken(value: string | null | undefined) {
|
||||
return value?.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function limitDigestText(value: string | null | undefined, maxLength = MAX_DIGEST_TEXT_LENGTH) {
|
||||
if (!value) return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed;
|
||||
}
|
||||
|
||||
function severityRank(severity: string | null | undefined) {
|
||||
switch (normalizeToken(severity)) {
|
||||
case "critical":
|
||||
return 5;
|
||||
case "high":
|
||||
return 4;
|
||||
case "medium":
|
||||
return 3;
|
||||
case "low":
|
||||
return 2;
|
||||
case "info":
|
||||
return 1;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function confidenceRank(confidence: string | null | undefined) {
|
||||
switch (normalizeToken(confidence)) {
|
||||
case "high":
|
||||
return 3;
|
||||
case "medium":
|
||||
return 2;
|
||||
case "low":
|
||||
return 1;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function isLowConfidence(value: unknown) {
|
||||
return typeof value === "string" && value.trim().toLowerCase() === "low";
|
||||
}
|
||||
|
||||
function getVisibleAgenticRiskFindings(analysis: LlmAnalysisLike | null | undefined) {
|
||||
return (analysis?.agenticRiskFindings ?? []).filter(
|
||||
(finding) =>
|
||||
(finding.status === "note" || finding.status === "concern") &&
|
||||
Boolean(finding.evidence) &&
|
||||
!isLowConfidence(finding.confidence),
|
||||
);
|
||||
}
|
||||
|
||||
function getHighestVisibleSeverity(analysis: LlmAnalysisLike | null | undefined) {
|
||||
let best: string | undefined;
|
||||
let bestRank = 0;
|
||||
for (const finding of getVisibleAgenticRiskFindings(analysis)) {
|
||||
const rank = severityRank(finding.severity);
|
||||
if (rank > bestRank) {
|
||||
best = finding.severity;
|
||||
bestRank = rank;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function getPrimaryClawScanFinding(analysis: LlmAnalysisLike | null | undefined) {
|
||||
let best: NonNullable<LlmAnalysisLike["agenticRiskFindings"]>[number] | undefined;
|
||||
for (const finding of getVisibleAgenticRiskFindings(analysis)) {
|
||||
if (!best) {
|
||||
best = finding;
|
||||
continue;
|
||||
}
|
||||
const severityDiff = severityRank(finding.severity) - severityRank(best.severity);
|
||||
if (severityDiff > 0) {
|
||||
best = finding;
|
||||
continue;
|
||||
}
|
||||
if (severityDiff < 0) continue;
|
||||
|
||||
const statusDiff = (finding.status === "concern" ? 1 : 0) - (best.status === "concern" ? 1 : 0);
|
||||
if (statusDiff > 0) {
|
||||
best = finding;
|
||||
continue;
|
||||
}
|
||||
if (statusDiff < 0) continue;
|
||||
|
||||
const confidenceDiff = confidenceRank(finding.confidence) - confidenceRank(best.confidence);
|
||||
if (confidenceDiff > 0) best = finding;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function normalizeClawScanVerdictToken(
|
||||
value: string | null | undefined,
|
||||
): ClawScanDigestVerdict | null {
|
||||
switch (normalizeToken(value)) {
|
||||
case "malicious":
|
||||
return "malicious";
|
||||
case "review":
|
||||
case "suspicious":
|
||||
case "warn":
|
||||
case "warning":
|
||||
return "suspicious";
|
||||
case "benign":
|
||||
case "clean":
|
||||
case "cleared":
|
||||
case "pass":
|
||||
case "undetected-only-fallback":
|
||||
return "pass";
|
||||
case "pending":
|
||||
case "loading":
|
||||
case "not_found":
|
||||
case "queued":
|
||||
case "running":
|
||||
return "pending";
|
||||
case "error":
|
||||
case "failed":
|
||||
return "failed";
|
||||
case "unknown":
|
||||
return "unknown";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clawScanVerdictFromLlmAnalysis(
|
||||
analysis: LlmAnalysisLike | null | undefined,
|
||||
): ClawScanDigestVerdict {
|
||||
const verdict = normalizeClawScanVerdictToken(analysis?.verdict);
|
||||
const status = normalizeClawScanVerdictToken(analysis?.status);
|
||||
const normalized = verdict ?? status;
|
||||
if (!normalized) return analysis ? "unknown" : "pending";
|
||||
if (
|
||||
normalized === "pass" &&
|
||||
severityRank(getHighestVisibleSeverity(analysis)) >= severityRank("medium")
|
||||
) {
|
||||
return "suspicious";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function pipelineStatusFromJob(
|
||||
job: Pick<SecurityScanJobLike, "status"> | null | undefined,
|
||||
): SecurityScanPipelineStatus {
|
||||
if (!job) return "none";
|
||||
return job.status;
|
||||
}
|
||||
|
||||
export function failureStatusFromJob(
|
||||
job: Pick<SecurityScanJobLike, "status"> | null | undefined,
|
||||
): SecurityScanFailureStatus {
|
||||
return job?.status === "failed" ? "failed" : "none";
|
||||
}
|
||||
|
||||
export function clawScanVerdictForState(params: {
|
||||
llmAnalysis?: LlmAnalysisLike | null;
|
||||
scanJobStatus?: SecurityScanPipelineStatus;
|
||||
}): ClawScanDigestVerdict {
|
||||
const fromAnalysis = clawScanVerdictFromLlmAnalysis(params.llmAnalysis);
|
||||
if (fromAnalysis !== "pending" || params.llmAnalysis) return fromAnalysis;
|
||||
if (params.scanJobStatus === "queued" || params.scanJobStatus === "running") return "pending";
|
||||
if (params.scanJobStatus === "failed") return "failed";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function toSecurityScanHourBucket(timestamp: number) {
|
||||
return Math.floor(timestamp / HOUR_MS) * HOUR_MS;
|
||||
}
|
||||
|
||||
export function clampSecurityScanDigestBackfillBatchSize(batchSize: number | null | undefined) {
|
||||
if (!Number.isFinite(batchSize ?? NaN)) return DEFAULT_BACKFILL_BATCH_SIZE;
|
||||
return Math.max(1, Math.min(MAX_BACKFILL_BATCH_SIZE, Math.floor(batchSize!)));
|
||||
}
|
||||
|
||||
function getSkillSpectorTopCategory(analysis: SkillSpectorAnalysisLike | null | undefined) {
|
||||
let best: NonNullable<SkillSpectorAnalysisLike["issues"]>[number] | undefined;
|
||||
for (const issue of analysis?.issues ?? []) {
|
||||
if (!issue.category) continue;
|
||||
if (!best || severityRank(issue.severity) > severityRank(best.severity)) best = issue;
|
||||
}
|
||||
return best?.category;
|
||||
}
|
||||
|
||||
function getVtEngineStats(analysis: VirusTotalAnalysisLike | null | undefined) {
|
||||
return analysis?.engineStats;
|
||||
}
|
||||
|
||||
function getEvidenceUpdatedAt(params: {
|
||||
llmAnalysis?: LlmAnalysisLike | null;
|
||||
skillSpectorAnalysis?: SkillSpectorAnalysisLike | null;
|
||||
staticScan?: StaticScanLike | null;
|
||||
vtAnalysis?: VirusTotalAnalysisLike | null;
|
||||
}) {
|
||||
const timestamps = [
|
||||
params.llmAnalysis?.checkedAt,
|
||||
params.skillSpectorAnalysis?.checkedAt,
|
||||
params.staticScan?.checkedAt,
|
||||
params.vtAnalysis?.checkedAt,
|
||||
].filter((value): value is number => typeof value === "number");
|
||||
return timestamps.length > 0 ? Math.max(...timestamps) : undefined;
|
||||
}
|
||||
|
||||
function getJobTiming(job: SecurityScanJobLike | null | undefined) {
|
||||
return {
|
||||
lastScanJobId: job?._id,
|
||||
lastScanJobSource: job?.source,
|
||||
lastScanWorkerId: job?.workerId,
|
||||
lastScanAttempts: job?.attempts,
|
||||
lastScanQueuedAt: job?.createdAt,
|
||||
lastScanStartedAt: job?.status === "running" ? job.updatedAt : undefined,
|
||||
lastScanCompletedAt:
|
||||
job?.status === "succeeded" ? (job.completedAt ?? job.updatedAt) : undefined,
|
||||
lastScanFailedAt: job?.status === "failed" ? job.updatedAt : undefined,
|
||||
lastScanUpdatedAt: job?.updatedAt,
|
||||
lastError: limitDigestText(job?.lastError),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSharedScanFields(params: {
|
||||
llmAnalysis?: LlmAnalysisLike | null;
|
||||
skillSpectorAnalysis?: SkillSpectorAnalysisLike | null;
|
||||
staticScan?: StaticScanLike | null;
|
||||
vtAnalysis?: VirusTotalAnalysisLike | null;
|
||||
scanJob?: SecurityScanJobLike | null;
|
||||
}) {
|
||||
const scanJobStatus = pipelineStatusFromJob(params.scanJob);
|
||||
const failureStatus = failureStatusFromJob(params.scanJob);
|
||||
const primaryFinding = getPrimaryClawScanFinding(params.llmAnalysis);
|
||||
const vtStats = getVtEngineStats(params.vtAnalysis);
|
||||
const clawScanVerdict = clawScanVerdictForState({
|
||||
llmAnalysis: params.llmAnalysis,
|
||||
scanJobStatus,
|
||||
});
|
||||
return {
|
||||
clawScanVerdict,
|
||||
clawScanStatus: params.llmAnalysis?.status,
|
||||
clawScanCheckedAt: params.llmAnalysis?.checkedAt,
|
||||
clawScanSummary: limitDigestText(params.llmAnalysis?.summary),
|
||||
clawScanModel: params.llmAnalysis?.model,
|
||||
clawScanPrimaryRiskBucket: primaryFinding?.riskBucket,
|
||||
clawScanPrimaryCategoryKey: primaryFinding?.categoryId,
|
||||
clawScanPrimaryCategoryLabel: primaryFinding?.categoryLabel,
|
||||
clawScanVisibleFindingCount: getVisibleAgenticRiskFindings(params.llmAnalysis).length,
|
||||
clawScanHighestSeverity: getHighestVisibleSeverity(params.llmAnalysis),
|
||||
scanJobStatus,
|
||||
failureStatus,
|
||||
...getJobTiming(params.scanJob),
|
||||
skillSpectorStatus: params.skillSpectorAnalysis?.status,
|
||||
skillSpectorScore: params.skillSpectorAnalysis?.score,
|
||||
skillSpectorSeverity: params.skillSpectorAnalysis?.severity,
|
||||
skillSpectorRecommendation: params.skillSpectorAnalysis?.recommendation,
|
||||
skillSpectorIssueCount: params.skillSpectorAnalysis?.issueCount,
|
||||
skillSpectorTopCategory: getSkillSpectorTopCategory(params.skillSpectorAnalysis),
|
||||
skillSpectorCheckedAt: params.skillSpectorAnalysis?.checkedAt,
|
||||
staticStatus: params.staticScan?.status,
|
||||
staticReasonCount: params.staticScan?.reasonCodes.length,
|
||||
staticCheckedAt: params.staticScan?.checkedAt,
|
||||
vtStatus: params.vtAnalysis?.status,
|
||||
vtVerdict: params.vtAnalysis?.verdict,
|
||||
vtMalicious: vtStats?.malicious,
|
||||
vtSuspicious: vtStats?.suspicious,
|
||||
vtCheckedAt: params.vtAnalysis?.checkedAt,
|
||||
evidenceUpdatedAt: getEvidenceUpdatedAt(params),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSkillSecurityScanArtifactState(params: {
|
||||
skill: SkillDigestInput;
|
||||
version: SkillVersionDigestInput;
|
||||
scanJob?: SecurityScanJobLike | null;
|
||||
now: number;
|
||||
}): SecurityScanArtifactStateFields {
|
||||
return {
|
||||
artifactKind: "skill",
|
||||
targetKind: "skillVersion",
|
||||
artifactKey: `skill:${params.skill._id}`,
|
||||
targetKey: `skillVersion:${params.version._id}`,
|
||||
skillId: params.skill._id,
|
||||
skillVersionId: params.version._id,
|
||||
ownerUserId: params.skill.ownerUserId,
|
||||
ownerPublisherId: params.skill.ownerPublisherId,
|
||||
slug: params.skill.slug,
|
||||
displayName: params.skill.displayName,
|
||||
version: params.version.version,
|
||||
...buildSharedScanFields({
|
||||
llmAnalysis: params.version.llmAnalysis,
|
||||
skillSpectorAnalysis: params.version.skillSpectorAnalysis,
|
||||
staticScan: params.version.staticScan,
|
||||
vtAnalysis: params.version.vtAnalysis,
|
||||
scanJob: params.scanJob,
|
||||
}),
|
||||
createdAt: params.now,
|
||||
updatedAt: params.now,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPluginSecurityScanArtifactState(params: {
|
||||
pkg: PackageDigestInput;
|
||||
release: PackageReleaseDigestInput;
|
||||
scanJob?: SecurityScanJobLike | null;
|
||||
now: number;
|
||||
}): SecurityScanArtifactStateFields {
|
||||
return {
|
||||
artifactKind: "plugin",
|
||||
targetKind: "packageRelease",
|
||||
artifactKey: `plugin:${params.pkg._id}`,
|
||||
targetKey: `packageRelease:${params.release._id}`,
|
||||
packageId: params.pkg._id,
|
||||
packageReleaseId: params.release._id,
|
||||
ownerUserId: params.pkg.ownerUserId,
|
||||
ownerPublisherId: params.pkg.ownerPublisherId,
|
||||
name: params.pkg.name,
|
||||
displayName: params.pkg.displayName,
|
||||
version: params.release.version,
|
||||
...buildSharedScanFields({
|
||||
llmAnalysis: params.release.llmAnalysis,
|
||||
skillSpectorAnalysis: params.release.skillSpectorAnalysis,
|
||||
staticScan: params.release.staticScan,
|
||||
vtAnalysis: params.release.vtAnalysis,
|
||||
scanJob: params.scanJob,
|
||||
}),
|
||||
createdAt: params.now,
|
||||
updatedAt: params.now,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCurrentRollupEntriesForState(
|
||||
state: Pick<
|
||||
SecurityScanArtifactStateFields,
|
||||
| "artifactKind"
|
||||
| "clawScanVerdict"
|
||||
| "scanJobStatus"
|
||||
| "failureStatus"
|
||||
| "clawScanPrimaryRiskBucket"
|
||||
| "clawScanPrimaryCategoryKey"
|
||||
| "clawScanPrimaryCategoryLabel"
|
||||
>,
|
||||
): SecurityScanCurrentRollupDimensions[] {
|
||||
const base = {
|
||||
artifactKind: state.artifactKind,
|
||||
clawScanVerdict: state.clawScanVerdict,
|
||||
scanJobStatus: state.scanJobStatus,
|
||||
failureStatus: state.failureStatus,
|
||||
};
|
||||
const entries: SecurityScanCurrentRollupDimensions[] = [
|
||||
{
|
||||
...base,
|
||||
rollupKind: "all",
|
||||
categoryKey: "all",
|
||||
categoryLabel: "All artifacts",
|
||||
},
|
||||
];
|
||||
if (state.clawScanPrimaryRiskBucket) {
|
||||
entries.push({
|
||||
...base,
|
||||
rollupKind: "clawscanRiskBucket",
|
||||
categoryKey: state.clawScanPrimaryRiskBucket,
|
||||
categoryLabel:
|
||||
RISK_BUCKET_LABELS[state.clawScanPrimaryRiskBucket] ?? state.clawScanPrimaryRiskBucket,
|
||||
});
|
||||
}
|
||||
if (state.clawScanPrimaryCategoryKey) {
|
||||
entries.push({
|
||||
...base,
|
||||
rollupKind: "clawscanCategory",
|
||||
categoryKey: state.clawScanPrimaryCategoryKey,
|
||||
categoryLabel: state.clawScanPrimaryCategoryLabel ?? state.clawScanPrimaryCategoryKey,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function rollupEntryKey(entry: SecurityScanCurrentRollupDimensions) {
|
||||
return [
|
||||
entry.artifactKind,
|
||||
entry.rollupKind,
|
||||
entry.categoryKey,
|
||||
entry.clawScanVerdict,
|
||||
entry.scanJobStatus,
|
||||
entry.failureStatus,
|
||||
].join("|");
|
||||
}
|
||||
|
||||
export function getCurrentRollupDeltas(
|
||||
previous: SecurityScanArtifactStateFields | null | undefined,
|
||||
next: SecurityScanArtifactStateFields | null | undefined,
|
||||
): SecurityScanRollupDelta[] {
|
||||
const deltas = new Map<string, SecurityScanRollupDelta>();
|
||||
const addDelta = (entry: SecurityScanCurrentRollupDimensions, delta: 1 | -1) => {
|
||||
const key = rollupEntryKey(entry);
|
||||
const existing = deltas.get(key);
|
||||
if (!existing) {
|
||||
deltas.set(key, { dimensions: entry, delta });
|
||||
return;
|
||||
}
|
||||
const combined = existing.delta + delta;
|
||||
if (combined === 0) {
|
||||
deltas.delete(key);
|
||||
return;
|
||||
}
|
||||
deltas.set(key, { dimensions: entry, delta: combined > 0 ? 1 : -1 });
|
||||
};
|
||||
if (previous) {
|
||||
for (const entry of getCurrentRollupEntriesForState(previous)) addDelta(entry, -1);
|
||||
}
|
||||
if (next) {
|
||||
for (const entry of getCurrentRollupEntriesForState(next)) addDelta(entry, 1);
|
||||
}
|
||||
return [...deltas.values()];
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearSecurityScanCurrentRollupsPage,
|
||||
deleteSecurityScanArtifactState,
|
||||
recordSecurityScanHourlyRollupEvent,
|
||||
rebuildSecurityScanCurrentRollupsFromStatesPage,
|
||||
upsertSecurityScanArtifactState,
|
||||
} from "../securityScanDigests";
|
||||
import { buildSkillSecurityScanArtifactState } from "./securityScanDigest";
|
||||
|
||||
type FakeRow = Record<string, unknown> & {
|
||||
_id: string;
|
||||
_creationTime: number;
|
||||
};
|
||||
|
||||
type FakeTable =
|
||||
| "securityScanArtifactStates"
|
||||
| "securityScanCurrentRollups"
|
||||
| "securityScanHourlyRollups"
|
||||
| "securityScanHourlyRollupEvents";
|
||||
|
||||
class FakeDb {
|
||||
private nextId = 1;
|
||||
readonly tables: Record<FakeTable, FakeRow[]> = {
|
||||
securityScanArtifactStates: [],
|
||||
securityScanCurrentRollups: [],
|
||||
securityScanHourlyRollups: [],
|
||||
securityScanHourlyRollupEvents: [],
|
||||
};
|
||||
|
||||
query(table: FakeTable) {
|
||||
const tables = this.tables;
|
||||
const filters: Array<[string, unknown]> = [];
|
||||
const range = {
|
||||
eq(field: string, value: unknown) {
|
||||
filters.push([field, value]);
|
||||
return range;
|
||||
},
|
||||
};
|
||||
const query = {
|
||||
withIndex(_indexName: string, buildRange: (q: typeof range) => unknown) {
|
||||
buildRange(range);
|
||||
return query;
|
||||
},
|
||||
async unique() {
|
||||
const matches = tables[table].filter((row) =>
|
||||
filters.every(([field, value]) => row[field] === value),
|
||||
);
|
||||
if (matches.length > 1) throw new Error(`Expected unique ${table} row`);
|
||||
return matches[0] ?? null;
|
||||
},
|
||||
async take(limit: number) {
|
||||
return tables[table]
|
||||
.filter((row) => filters.every(([field, value]) => row[field] === value))
|
||||
.slice(0, limit);
|
||||
},
|
||||
async paginate(opts: { cursor: string | null; numItems: number }) {
|
||||
const offset = opts.cursor ? Number(opts.cursor) : 0;
|
||||
const matches = tables[table].filter((row) =>
|
||||
filters.every(([field, value]) => row[field] === value),
|
||||
);
|
||||
const page = matches.slice(offset, offset + opts.numItems);
|
||||
const nextOffset = offset + page.length;
|
||||
const isDone = nextOffset >= matches.length;
|
||||
return {
|
||||
page,
|
||||
isDone,
|
||||
continueCursor: isDone ? null : String(nextOffset),
|
||||
};
|
||||
},
|
||||
};
|
||||
return query;
|
||||
}
|
||||
|
||||
async insert(table: FakeTable, fields: Record<string, unknown>) {
|
||||
const row = {
|
||||
...fields,
|
||||
_id: `${table}:${this.nextId}`,
|
||||
_creationTime: this.nextId,
|
||||
};
|
||||
this.nextId++;
|
||||
this.tables[table].push(row);
|
||||
return row._id;
|
||||
}
|
||||
|
||||
async patch(id: string, fields: Record<string, unknown>) {
|
||||
const row = this.findById(id);
|
||||
Object.assign(row, fields);
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
for (const rows of Object.values(this.tables)) {
|
||||
const index = rows.findIndex((row) => row._id === id);
|
||||
if (index >= 0) {
|
||||
rows.splice(index, 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error(`Missing row ${id}`);
|
||||
}
|
||||
|
||||
private findById(id: string) {
|
||||
for (const row of Object.values(this.tables).flat()) {
|
||||
if (row._id === id) return row;
|
||||
}
|
||||
throw new Error(`Missing row ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
const checkedAt = Date.UTC(2026, 0, 1, 12, 15, 0);
|
||||
|
||||
function makeCtx() {
|
||||
const db = new FakeDb();
|
||||
return { db, ctx: { db } as never };
|
||||
}
|
||||
|
||||
function makeSkillState(verdict: "pass" | "malicious" = "pass", now = checkedAt) {
|
||||
return buildSkillSecurityScanArtifactState({
|
||||
skill: {
|
||||
_id: "skills:abc" as never,
|
||||
slug: "demo-skill",
|
||||
displayName: "Demo Skill",
|
||||
ownerUserId: "users:owner" as never,
|
||||
ownerPublisherId: "publishers:owner" as never,
|
||||
},
|
||||
version: {
|
||||
_id: "skillVersions:v1" as never,
|
||||
version: "1.0.0",
|
||||
createdAt: checkedAt - 1_000,
|
||||
vtAnalysis: undefined,
|
||||
skillSpectorAnalysis: undefined,
|
||||
staticScan: undefined,
|
||||
llmAnalysis:
|
||||
verdict === "malicious"
|
||||
? {
|
||||
status: "malicious",
|
||||
verdict: "malicious",
|
||||
checkedAt,
|
||||
}
|
||||
: {
|
||||
status: "clean",
|
||||
verdict: "benign",
|
||||
checkedAt,
|
||||
},
|
||||
} as never,
|
||||
now,
|
||||
});
|
||||
}
|
||||
|
||||
describe("security scan digest mutation helpers", () => {
|
||||
it("keeps current rollups idempotent across upserts and verdict changes", async () => {
|
||||
const { db, ctx } = makeCtx();
|
||||
await upsertSecurityScanArtifactState(ctx, makeSkillState("pass"));
|
||||
await upsertSecurityScanArtifactState(ctx, makeSkillState("pass", checkedAt + 1));
|
||||
|
||||
expect(db.tables.securityScanArtifactStates).toHaveLength(1);
|
||||
expect(db.tables.securityScanCurrentRollups).toMatchObject([
|
||||
{ clawScanVerdict: "pass", count: 1 },
|
||||
]);
|
||||
|
||||
await upsertSecurityScanArtifactState(ctx, makeSkillState("malicious", checkedAt + 2));
|
||||
|
||||
expect(db.tables.securityScanArtifactStates).toHaveLength(1);
|
||||
expect(db.tables.securityScanCurrentRollups).toMatchObject([
|
||||
{ clawScanVerdict: "malicious", count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("decrements current rollups when an artifact state is pruned", async () => {
|
||||
const { db, ctx } = makeCtx();
|
||||
await upsertSecurityScanArtifactState(ctx, makeSkillState("pass"));
|
||||
|
||||
await deleteSecurityScanArtifactState(
|
||||
ctx,
|
||||
db.tables.securityScanArtifactStates[0] as never,
|
||||
checkedAt + 1,
|
||||
);
|
||||
|
||||
expect(db.tables.securityScanArtifactStates).toHaveLength(0);
|
||||
expect(db.tables.securityScanCurrentRollups).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("repairs drifted current rollups by clearing and rebuilding from artifact states", async () => {
|
||||
const { db, ctx } = makeCtx();
|
||||
await upsertSecurityScanArtifactState(ctx, makeSkillState("pass"));
|
||||
db.tables.securityScanCurrentRollups[0].count = 42;
|
||||
|
||||
expect(await clearSecurityScanCurrentRollupsPage(ctx, { artifactKind: "skill" })).toMatchObject(
|
||||
{
|
||||
deletedCount: 1,
|
||||
isDone: true,
|
||||
},
|
||||
);
|
||||
expect(
|
||||
await rebuildSecurityScanCurrentRollupsFromStatesPage(ctx, {
|
||||
artifactKind: "skill",
|
||||
batchSize: 10,
|
||||
}),
|
||||
).toMatchObject({
|
||||
scannedCount: 1,
|
||||
rollupDeltaCount: 1,
|
||||
isDone: true,
|
||||
});
|
||||
|
||||
expect(db.tables.securityScanCurrentRollups).toMatchObject([
|
||||
{ artifactKind: "skill", clawScanVerdict: "pass", count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("records hourly rollup events idempotently by event key", async () => {
|
||||
const { db, ctx } = makeCtx();
|
||||
const dimensions = {
|
||||
artifactKind: "skill" as const,
|
||||
clawScanVerdict: "pass" as const,
|
||||
scanJobStatus: "succeeded" as const,
|
||||
failureStatus: "none" as const,
|
||||
};
|
||||
|
||||
await recordSecurityScanHourlyRollupEvent(ctx, {
|
||||
eventKey: "securityScanJobs:job1:succeeded",
|
||||
occurredAt: checkedAt,
|
||||
dimensions,
|
||||
});
|
||||
const duplicate = await recordSecurityScanHourlyRollupEvent(ctx, {
|
||||
eventKey: "securityScanJobs:job1:succeeded",
|
||||
occurredAt: checkedAt,
|
||||
dimensions,
|
||||
});
|
||||
|
||||
expect(duplicate).toMatchObject({ duplicate: true, updated: false });
|
||||
expect(db.tables.securityScanHourlyRollupEvents).toHaveLength(1);
|
||||
expect(db.tables.securityScanHourlyRollups).toMatchObject([
|
||||
{ artifactKind: "skill", clawScanVerdict: "pass", count: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
+202
-1
@@ -403,6 +403,32 @@ const skillCardGenerationJobSourceValidator = v.union(
|
||||
v.literal("scan"),
|
||||
v.literal("manual"),
|
||||
);
|
||||
const securityScanArtifactKindValidator = v.union(v.literal("skill"), v.literal("plugin"));
|
||||
const securityScanDigestTargetKindValidator = v.union(
|
||||
v.literal("skillVersion"),
|
||||
v.literal("packageRelease"),
|
||||
);
|
||||
const clawScanDigestVerdictValidator = v.union(
|
||||
v.literal("pass"),
|
||||
v.literal("suspicious"),
|
||||
v.literal("malicious"),
|
||||
v.literal("pending"),
|
||||
v.literal("failed"),
|
||||
v.literal("unknown"),
|
||||
);
|
||||
const securityScanPipelineStatusValidator = v.union(
|
||||
v.literal("none"),
|
||||
v.literal("queued"),
|
||||
v.literal("running"),
|
||||
v.literal("succeeded"),
|
||||
v.literal("failed"),
|
||||
);
|
||||
const securityScanFailureStatusValidator = v.union(v.literal("none"), v.literal("failed"));
|
||||
const securityScanRollupKindValidator = v.union(
|
||||
v.literal("all"),
|
||||
v.literal("clawscanRiskBucket"),
|
||||
v.literal("clawscanCategory"),
|
||||
);
|
||||
|
||||
const packageFilesValidator = v.array(
|
||||
v.object({
|
||||
@@ -970,6 +996,7 @@ const packages = defineTable({
|
||||
"updatedAt",
|
||||
])
|
||||
.index("by_family_updated", ["family", "updatedAt"])
|
||||
.index("by_family_and_soft_deleted_at_and_updated_at", ["family", "softDeletedAt", "updatedAt"])
|
||||
.index("by_family_channel_updated", ["family", "channel", "updatedAt"])
|
||||
.index("by_family_official_updated", ["family", "isOfficial", "updatedAt"])
|
||||
.index("by_runtime_id", ["runtimeId"])
|
||||
@@ -1094,7 +1121,176 @@ const securityScanJobs = defineTable({
|
||||
.index("by_status_and_lease_expires_at", ["status", "leaseExpiresAt"])
|
||||
.index("by_status_malicious_signal_next_run_at", ["status", "hasMaliciousSignal", "nextRunAt"])
|
||||
.index("by_skill_version", ["skillVersionId"])
|
||||
.index("by_package_release", ["packageReleaseId"]);
|
||||
.index("by_skill_version_and_updated_at", ["skillVersionId", "updatedAt"])
|
||||
.index("by_package_release", ["packageReleaseId"])
|
||||
.index("by_package_release_and_updated_at", ["packageReleaseId", "updatedAt"]);
|
||||
|
||||
const securityScanArtifactStates = defineTable({
|
||||
artifactKind: securityScanArtifactKindValidator,
|
||||
targetKind: securityScanDigestTargetKindValidator,
|
||||
artifactKey: v.string(),
|
||||
targetKey: v.string(),
|
||||
skillId: v.optional(v.id("skills")),
|
||||
skillVersionId: v.optional(v.id("skillVersions")),
|
||||
packageId: v.optional(v.id("packages")),
|
||||
packageReleaseId: v.optional(v.id("packageReleases")),
|
||||
ownerUserId: v.id("users"),
|
||||
ownerPublisherId: v.optional(v.id("publishers")),
|
||||
slug: v.optional(v.string()),
|
||||
name: v.optional(v.string()),
|
||||
displayName: v.string(),
|
||||
version: v.optional(v.string()),
|
||||
clawScanVerdict: clawScanDigestVerdictValidator,
|
||||
clawScanStatus: v.optional(v.string()),
|
||||
clawScanCheckedAt: v.optional(v.number()),
|
||||
clawScanSummary: v.optional(v.string()),
|
||||
clawScanModel: v.optional(v.string()),
|
||||
clawScanPrimaryRiskBucket: v.optional(v.string()),
|
||||
clawScanPrimaryCategoryKey: v.optional(v.string()),
|
||||
clawScanPrimaryCategoryLabel: v.optional(v.string()),
|
||||
clawScanVisibleFindingCount: v.optional(v.number()),
|
||||
clawScanHighestSeverity: v.optional(v.string()),
|
||||
scanJobStatus: securityScanPipelineStatusValidator,
|
||||
failureStatus: securityScanFailureStatusValidator,
|
||||
lastScanJobId: v.optional(v.id("securityScanJobs")),
|
||||
lastScanJobSource: v.optional(securityScanJobSourceValidator),
|
||||
lastScanWorkerId: v.optional(v.string()),
|
||||
lastScanAttempts: v.optional(v.number()),
|
||||
lastScanQueuedAt: v.optional(v.number()),
|
||||
lastScanStartedAt: v.optional(v.number()),
|
||||
lastScanCompletedAt: v.optional(v.number()),
|
||||
lastScanFailedAt: v.optional(v.number()),
|
||||
lastScanUpdatedAt: v.optional(v.number()),
|
||||
lastError: v.optional(v.string()),
|
||||
skillSpectorStatus: v.optional(v.string()),
|
||||
skillSpectorScore: v.optional(v.number()),
|
||||
skillSpectorSeverity: v.optional(v.string()),
|
||||
skillSpectorRecommendation: v.optional(v.string()),
|
||||
skillSpectorIssueCount: v.optional(v.number()),
|
||||
skillSpectorTopCategory: v.optional(v.string()),
|
||||
skillSpectorCheckedAt: v.optional(v.number()),
|
||||
staticStatus: v.optional(v.string()),
|
||||
staticReasonCount: v.optional(v.number()),
|
||||
staticCheckedAt: v.optional(v.number()),
|
||||
vtStatus: v.optional(v.string()),
|
||||
vtVerdict: v.optional(v.string()),
|
||||
vtMalicious: v.optional(v.number()),
|
||||
vtSuspicious: v.optional(v.number()),
|
||||
vtCheckedAt: v.optional(v.number()),
|
||||
evidenceUpdatedAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_artifact_kind_and_artifact_key", ["artifactKind", "artifactKey"])
|
||||
.index("by_artifact_kind_and_updated_at", ["artifactKind", "updatedAt"])
|
||||
.index("by_target_kind_and_target_key", ["targetKind", "targetKey"])
|
||||
.index("by_artifact_kind_and_claw_scan_verdict_and_updated_at", [
|
||||
"artifactKind",
|
||||
"clawScanVerdict",
|
||||
"updatedAt",
|
||||
])
|
||||
.index("by_artifact_kind_and_scan_job_status_and_updated_at", [
|
||||
"artifactKind",
|
||||
"scanJobStatus",
|
||||
"updatedAt",
|
||||
])
|
||||
.index("by_artifact_kind_and_failure_status_and_updated_at", [
|
||||
"artifactKind",
|
||||
"failureStatus",
|
||||
"updatedAt",
|
||||
])
|
||||
.index("by_kind_claw_category_updated_at", [
|
||||
"artifactKind",
|
||||
"clawScanPrimaryCategoryKey",
|
||||
"updatedAt",
|
||||
])
|
||||
.index("by_artifact_kind_and_display_name", ["artifactKind", "displayName"]);
|
||||
|
||||
const securityScanCurrentRollups = defineTable({
|
||||
artifactKind: securityScanArtifactKindValidator,
|
||||
rollupKind: securityScanRollupKindValidator,
|
||||
categoryKey: v.string(),
|
||||
categoryLabel: v.optional(v.string()),
|
||||
clawScanVerdict: clawScanDigestVerdictValidator,
|
||||
scanJobStatus: securityScanPipelineStatusValidator,
|
||||
failureStatus: securityScanFailureStatusValidator,
|
||||
count: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_artifact_kind_and_rollup_kind_and_category_key", [
|
||||
"artifactKind",
|
||||
"rollupKind",
|
||||
"categoryKey",
|
||||
])
|
||||
.index("by_kind_rollup_category_verdict_job_failure", [
|
||||
"artifactKind",
|
||||
"rollupKind",
|
||||
"categoryKey",
|
||||
"clawScanVerdict",
|
||||
"scanJobStatus",
|
||||
"failureStatus",
|
||||
]);
|
||||
|
||||
const securityScanHourlyRollups = defineTable({
|
||||
bucketStartMs: v.number(),
|
||||
artifactKind: securityScanArtifactKindValidator,
|
||||
clawScanVerdict: clawScanDigestVerdictValidator,
|
||||
scanJobStatus: securityScanPipelineStatusValidator,
|
||||
failureStatus: securityScanFailureStatusValidator,
|
||||
count: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_bucket_start_ms_and_artifact_kind", ["bucketStartMs", "artifactKind"])
|
||||
.index("by_artifact_kind_and_bucket_start_ms", ["artifactKind", "bucketStartMs"])
|
||||
.index("by_artifact_kind_and_bucket_start_ms_and_claw_scan_verdict", [
|
||||
"artifactKind",
|
||||
"bucketStartMs",
|
||||
"clawScanVerdict",
|
||||
])
|
||||
.index("by_artifact_kind_and_bucket_start_ms_and_scan_job_status", [
|
||||
"artifactKind",
|
||||
"bucketStartMs",
|
||||
"scanJobStatus",
|
||||
])
|
||||
.index("by_artifact_kind_and_bucket_start_ms_and_failure_status", [
|
||||
"artifactKind",
|
||||
"bucketStartMs",
|
||||
"failureStatus",
|
||||
])
|
||||
.index("by_bucket_kind_verdict_job_failure", [
|
||||
"bucketStartMs",
|
||||
"artifactKind",
|
||||
"clawScanVerdict",
|
||||
"scanJobStatus",
|
||||
"failureStatus",
|
||||
]);
|
||||
|
||||
const securityScanHourlyRollupEvents = defineTable({
|
||||
eventKey: v.string(),
|
||||
bucketStartMs: v.number(),
|
||||
artifactKind: securityScanArtifactKindValidator,
|
||||
clawScanVerdict: clawScanDigestVerdictValidator,
|
||||
scanJobStatus: securityScanPipelineStatusValidator,
|
||||
failureStatus: securityScanFailureStatusValidator,
|
||||
count: v.number(),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_event_key", ["eventKey"])
|
||||
.index("by_bucket_start_ms_and_artifact_kind", ["bucketStartMs", "artifactKind"]);
|
||||
|
||||
const securityScanDigestMetadata = defineTable({
|
||||
key: v.string(),
|
||||
artifactKind: v.optional(securityScanArtifactKindValidator),
|
||||
cursor: v.optional(v.union(v.string(), v.null())),
|
||||
isDone: v.boolean(),
|
||||
scannedCount: v.number(),
|
||||
upsertedCount: v.number(),
|
||||
skippedCount: v.number(),
|
||||
startedAt: v.optional(v.number()),
|
||||
completedAt: v.optional(v.number()),
|
||||
lastError: v.optional(v.string()),
|
||||
updatedAt: v.number(),
|
||||
}).index("by_key", ["key"]);
|
||||
|
||||
const skillCardGenerationJobs = defineTable({
|
||||
skillId: v.id("skills"),
|
||||
@@ -1985,6 +2181,11 @@ export default defineSchema({
|
||||
packages,
|
||||
packageReleases,
|
||||
securityScanJobs,
|
||||
securityScanArtifactStates,
|
||||
securityScanCurrentRollups,
|
||||
securityScanHourlyRollups,
|
||||
securityScanHourlyRollupEvents,
|
||||
securityScanDigestMetadata,
|
||||
skillCardGenerationJobs,
|
||||
packageStatEvents,
|
||||
packageTrustedPublishers,
|
||||
|
||||
+573
-4
@@ -6,9 +6,12 @@ import {
|
||||
clearQueuedBackfillJobsForLocalDev,
|
||||
claimQueuedJobsInternal,
|
||||
completeCodexScanJob,
|
||||
failJobInternal,
|
||||
failCodexScanJob,
|
||||
refreshJobDigestInternal,
|
||||
requestPackageRescanForUserInternal,
|
||||
requestPackageRescan,
|
||||
succeedJobInternal,
|
||||
requestSkillRescanForUserInternal,
|
||||
requestSkillRescan,
|
||||
} from "./securityScan";
|
||||
@@ -35,6 +38,27 @@ const claimQueuedJobsInternalHandler = (
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const succeedJobInternalHandler = (
|
||||
succeedJobInternal as unknown as WrappedHandler<
|
||||
{ jobId: string; leaseToken: string; runId?: string },
|
||||
{ ok: true }
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const failJobInternalHandler = (
|
||||
failJobInternal as unknown as WrappedHandler<
|
||||
{ jobId: string; leaseToken: string; error: string },
|
||||
{ ok: true; retry: boolean }
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const refreshJobDigestInternalHandler = (
|
||||
refreshJobDigestInternal as unknown as WrappedHandler<
|
||||
{ jobId: string },
|
||||
{ synced: boolean; artifactKind?: string }
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const failCodexScanJobHandler = (
|
||||
failCodexScanJob as unknown as WrappedHandler<
|
||||
{ token: string; jobId: string; leaseToken: string; error: string },
|
||||
@@ -272,6 +296,16 @@ function makeRescanCtx(options: {
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
if (table === "securityScanArtifactStates") {
|
||||
return (
|
||||
Array.from(docs.values()).find(
|
||||
(doc) =>
|
||||
doc._id?.toString().startsWith("securityScanArtifactStates:") &&
|
||||
doc.artifactKind === equals.get("artifactKind") &&
|
||||
doc.artifactKey === equals.get("artifactKey"),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
@@ -349,13 +383,40 @@ function makeCancelCtx(jobs: ScanJob[], targets: Map<string, unknown> = new Map(
|
||||
};
|
||||
}
|
||||
|
||||
function makeClaimCtx(jobs: ScanJob[]) {
|
||||
function makeClaimCtx(
|
||||
jobs: ScanJob[],
|
||||
targetDocs: Map<string, Record<string, unknown>> = new Map(),
|
||||
) {
|
||||
const patches: Array<{ id: string; patch: Record<string, unknown> }> = [];
|
||||
const inserts: Array<{ table: string; doc: Record<string, unknown> }> = [];
|
||||
const patch = vi.fn(async (id: string, doc: Record<string, unknown>) => {
|
||||
patches.push({ id, patch: doc });
|
||||
});
|
||||
const insert = vi.fn(async (table: string, doc: Record<string, unknown>) => {
|
||||
const id = `${table}:${inserts.length + 1}`;
|
||||
inserts.push({ table, doc });
|
||||
return id;
|
||||
});
|
||||
const get = vi.fn(async (id: string) => targetDocs.get(id) ?? null);
|
||||
const query = vi.fn((tableName: string) => {
|
||||
expect(tableName).toBe("securityScanJobs");
|
||||
if (tableName !== "securityScanJobs") {
|
||||
return {
|
||||
withIndex: vi.fn(
|
||||
(
|
||||
_indexName: string,
|
||||
buildRange: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
||||
) => {
|
||||
function eq() {
|
||||
return { eq };
|
||||
}
|
||||
buildRange({ eq });
|
||||
return {
|
||||
unique: vi.fn(async () => null),
|
||||
};
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
withIndex: vi.fn(
|
||||
(
|
||||
@@ -415,8 +476,8 @@ function makeClaimCtx(jobs: ScanJob[]) {
|
||||
db: {
|
||||
query,
|
||||
patch,
|
||||
get: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
get,
|
||||
insert,
|
||||
replace: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
normalizeId: vi.fn(() => null),
|
||||
@@ -424,7 +485,10 @@ function makeClaimCtx(jobs: ScanJob[]) {
|
||||
},
|
||||
},
|
||||
patches,
|
||||
inserts,
|
||||
patch,
|
||||
insert,
|
||||
get,
|
||||
query,
|
||||
};
|
||||
}
|
||||
@@ -443,6 +507,7 @@ describe("securityScan", () => {
|
||||
"skills:1": {
|
||||
_id: "skills:1",
|
||||
slug: "demo-skill",
|
||||
displayName: "Demo Skill",
|
||||
ownerUserId: "users:owner",
|
||||
latestVersionId: "skillVersions:1",
|
||||
},
|
||||
@@ -485,6 +550,31 @@ describe("securityScan", () => {
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(inserts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
table: "securityScanArtifactStates",
|
||||
doc: expect.objectContaining({
|
||||
artifactKind: "skill",
|
||||
artifactKey: "skill:skills:1",
|
||||
targetKey: "skillVersion:skillVersions:1",
|
||||
scanJobStatus: "queued",
|
||||
clawScanVerdict: "pending",
|
||||
lastScanJobId: "securityScanJobs:1",
|
||||
lastScanJobSource: "manual",
|
||||
lastScanAttempts: 0,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
table: "securityScanHourlyRollupEvents",
|
||||
doc: expect.objectContaining({
|
||||
eventKey: "securityScanJobs:1:queued:created",
|
||||
artifactKind: "skill",
|
||||
scanJobStatus: "queued",
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("lets skill owners request skill rescans through the API helper", async () => {
|
||||
@@ -529,6 +619,66 @@ describe("securityScan", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes current digest state without replaying an hourly event for duplicate active rescans", async () => {
|
||||
const activeJob = makeScanJob({
|
||||
_id: "securityScanJobs:active",
|
||||
source: "publish",
|
||||
skillVersionId: "skillVersions:1",
|
||||
createdAt: 50,
|
||||
updatedAt: 75,
|
||||
});
|
||||
const { ctx, inserts, patches } = makeRescanCtx({
|
||||
actorId: "users:moderator",
|
||||
actorRole: "moderator",
|
||||
activeJobs: [activeJob],
|
||||
docs: {
|
||||
"skills:1": {
|
||||
_id: "skills:1",
|
||||
slug: "demo-skill",
|
||||
displayName: "Demo Skill",
|
||||
ownerUserId: "users:owner",
|
||||
latestVersionId: "skillVersions:1",
|
||||
},
|
||||
"skillVersions:1": {
|
||||
_id: "skillVersions:1",
|
||||
skillId: "skills:1",
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await requestSkillRescanHandler(ctx, {
|
||||
skillId: "skills:1",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
jobId: "securityScanJobs:active",
|
||||
alreadyQueued: true,
|
||||
});
|
||||
expect(patches).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "securityScanJobs:active",
|
||||
patch: expect.objectContaining({ source: "manual", priority: 100 }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(inserts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
table: "securityScanArtifactStates",
|
||||
doc: expect.objectContaining({
|
||||
scanJobStatus: "queued",
|
||||
lastScanJobId: "securityScanJobs:active",
|
||||
lastScanJobSource: "manual",
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(inserts.some((entry) => entry.table === "securityScanHourlyRollupEvents")).toBe(false);
|
||||
});
|
||||
|
||||
it("lets platform moderators request package rescans", async () => {
|
||||
const { ctx, inserts } = makeRescanCtx({
|
||||
actorId: "users:moderator",
|
||||
@@ -537,6 +687,7 @@ describe("securityScan", () => {
|
||||
"packages:1": {
|
||||
_id: "packages:1",
|
||||
name: "@acme/demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
normalizedName: "@acme/demo-plugin",
|
||||
family: "plugin",
|
||||
ownerUserId: "users:owner",
|
||||
@@ -582,6 +733,31 @@ describe("securityScan", () => {
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(inserts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
table: "securityScanArtifactStates",
|
||||
doc: expect.objectContaining({
|
||||
artifactKind: "plugin",
|
||||
artifactKey: "plugin:packages:1",
|
||||
targetKey: "packageRelease:packageReleases:1",
|
||||
scanJobStatus: "queued",
|
||||
clawScanVerdict: "pending",
|
||||
lastScanJobId: "securityScanJobs:1",
|
||||
lastScanJobSource: "manual",
|
||||
lastScanAttempts: 0,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
table: "securityScanHourlyRollupEvents",
|
||||
doc: expect.objectContaining({
|
||||
eventKey: "securityScanJobs:1:queued:created",
|
||||
artifactKind: "plugin",
|
||||
scanJobStatus: "queued",
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("lets package owners request package rescans through the API helper", async () => {
|
||||
@@ -951,6 +1127,71 @@ describe("securityScan", () => {
|
||||
expect(patches.map((entry) => entry.id)).toEqual(claimed.map((job) => job._id));
|
||||
});
|
||||
|
||||
it("updates digest state and hourly rollups when a queued skill scan is claimed", async () => {
|
||||
const { ctx, inserts } = makeClaimCtx(
|
||||
[
|
||||
makeScanJob({
|
||||
_id: "securityScanJobs:manual",
|
||||
source: "manual",
|
||||
skillVersionId: "skillVersions:manual",
|
||||
priority: 100,
|
||||
createdAt: 100,
|
||||
nextRunAt: 100,
|
||||
}),
|
||||
],
|
||||
new Map<string, Record<string, unknown>>([
|
||||
[
|
||||
"skillVersions:manual",
|
||||
{
|
||||
_id: "skillVersions:manual",
|
||||
skillId: "skills:manual",
|
||||
version: "1.0.0",
|
||||
createdAt: 100,
|
||||
},
|
||||
],
|
||||
[
|
||||
"skills:manual",
|
||||
{
|
||||
_id: "skills:manual",
|
||||
slug: "manual-skill",
|
||||
displayName: "Manual Skill",
|
||||
ownerUserId: "users:owner",
|
||||
latestVersionId: "skillVersions:manual",
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const claimed = await claimQueuedJobsInternalHandler(ctx, {
|
||||
workerId: "worker-1",
|
||||
limit: 1,
|
||||
leaseMs: 60_000,
|
||||
});
|
||||
|
||||
expect(claimed).toHaveLength(1);
|
||||
expect(inserts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
table: "securityScanArtifactStates",
|
||||
doc: expect.objectContaining({
|
||||
artifactKind: "skill",
|
||||
scanJobStatus: "running",
|
||||
lastScanJobId: "securityScanJobs:manual",
|
||||
lastScanWorkerId: "worker-1",
|
||||
lastScanAttempts: 1,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
table: "securityScanHourlyRollupEvents",
|
||||
doc: expect.objectContaining({
|
||||
eventKey: "securityScanJobs:manual:running:1",
|
||||
scanJobStatus: "running",
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows up to 64 active Codex scan claims", async () => {
|
||||
const { ctx } = makeClaimCtx(
|
||||
Array.from({ length: 70 }, (_, index) =>
|
||||
@@ -973,6 +1214,327 @@ describe("securityScan", () => {
|
||||
expect(claimed).toHaveLength(64);
|
||||
});
|
||||
|
||||
it("updates digest verdict and hourly rollups when a skill scan succeeds", async () => {
|
||||
const { ctx, inserts, patches } = makeRescanCtx({
|
||||
actorId: "users:owner",
|
||||
docs: {
|
||||
"securityScanJobs:success": {
|
||||
...makeScanJob({
|
||||
_id: "securityScanJobs:success",
|
||||
status: "running",
|
||||
source: "publish",
|
||||
skillVersionId: "skillVersions:success",
|
||||
attempts: 1,
|
||||
createdAt: 100,
|
||||
updatedAt: 200,
|
||||
}),
|
||||
leaseToken: "lease-token",
|
||||
workerId: "worker-1",
|
||||
},
|
||||
"skills:success": {
|
||||
_id: "skills:success",
|
||||
slug: "success-skill",
|
||||
displayName: "Success Skill",
|
||||
ownerUserId: "users:owner",
|
||||
latestVersionId: "skillVersions:success",
|
||||
},
|
||||
"skillVersions:success": {
|
||||
_id: "skillVersions:success",
|
||||
skillId: "skills:success",
|
||||
version: "1.0.0",
|
||||
createdAt: 100,
|
||||
llmAnalysis: {
|
||||
status: "malicious",
|
||||
verdict: "malicious",
|
||||
checkedAt: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
succeedJobInternalHandler(ctx, {
|
||||
jobId: "securityScanJobs:success",
|
||||
leaseToken: "lease-token",
|
||||
runId: "run-1",
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(patches).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "securityScanJobs:success",
|
||||
patch: expect.objectContaining({ status: "succeeded", runId: "run-1" }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(inserts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
table: "securityScanArtifactStates",
|
||||
doc: expect.objectContaining({
|
||||
artifactKind: "skill",
|
||||
scanJobStatus: "succeeded",
|
||||
failureStatus: "none",
|
||||
clawScanVerdict: "malicious",
|
||||
lastScanJobId: "securityScanJobs:success",
|
||||
lastScanWorkerId: "worker-1",
|
||||
lastScanAttempts: 1,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
table: "securityScanHourlyRollupEvents",
|
||||
doc: expect.objectContaining({
|
||||
eventKey: "securityScanJobs:success:succeeded:run-1",
|
||||
scanJobStatus: "succeeded",
|
||||
clawScanVerdict: "malicious",
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps retried worker failures queued in digest state without failed rollup status", async () => {
|
||||
const { ctx, inserts, patches } = makeRescanCtx({
|
||||
actorId: "users:owner",
|
||||
docs: {
|
||||
"securityScanJobs:retry": {
|
||||
...makeScanJob({
|
||||
_id: "securityScanJobs:retry",
|
||||
status: "running",
|
||||
source: "publish",
|
||||
skillVersionId: "skillVersions:retry",
|
||||
attempts: 1,
|
||||
createdAt: 100,
|
||||
updatedAt: 200,
|
||||
}),
|
||||
leaseToken: "lease-token",
|
||||
workerId: "worker-1",
|
||||
},
|
||||
"skills:retry": {
|
||||
_id: "skills:retry",
|
||||
slug: "retry-skill",
|
||||
displayName: "Retry Skill",
|
||||
ownerUserId: "users:owner",
|
||||
latestVersionId: "skillVersions:retry",
|
||||
},
|
||||
"skillVersions:retry": {
|
||||
_id: "skillVersions:retry",
|
||||
skillId: "skills:retry",
|
||||
version: "1.0.0",
|
||||
createdAt: 100,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await failJobInternalHandler(ctx, {
|
||||
jobId: "securityScanJobs:retry",
|
||||
leaseToken: "lease-token",
|
||||
error: "Download failed https://signed.example.invalid/file?token=secret Bearer sk-secret",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, retry: true });
|
||||
expect(patches).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "securityScanJobs:retry",
|
||||
patch: expect.objectContaining({ status: "queued" }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const failurePatch = patches.find((entry) => entry.id === "securityScanJobs:retry")?.patch;
|
||||
expect(failurePatch?.lastError).not.toContain("token=secret");
|
||||
expect(failurePatch?.lastError).not.toContain("sk-secret");
|
||||
expect(inserts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
table: "securityScanArtifactStates",
|
||||
doc: expect.objectContaining({
|
||||
scanJobStatus: "queued",
|
||||
failureStatus: "none",
|
||||
clawScanVerdict: "pending",
|
||||
lastScanWorkerId: "worker-1",
|
||||
lastScanAttempts: 1,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
table: "securityScanHourlyRollupEvents",
|
||||
doc: expect.objectContaining({
|
||||
eventKey: "securityScanJobs:retry:retry:1",
|
||||
scanJobStatus: "queued",
|
||||
failureStatus: "none",
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const state = inserts.find((entry) => entry.table === "securityScanArtifactStates")?.doc;
|
||||
expect(state?.lastError).not.toContain("token=secret");
|
||||
expect(state?.lastError).not.toContain("sk-secret");
|
||||
});
|
||||
|
||||
it("records final package scan failures with plugin digest failure metadata", async () => {
|
||||
const { ctx, inserts, patches } = makeRescanCtx({
|
||||
actorId: "users:owner",
|
||||
docs: {
|
||||
"securityScanJobs:failed": {
|
||||
...makeScanJob({
|
||||
_id: "securityScanJobs:failed",
|
||||
status: "running",
|
||||
targetKind: "packageRelease",
|
||||
skillVersionId: undefined,
|
||||
packageReleaseId: "packageReleases:failed",
|
||||
source: "manual",
|
||||
attempts: 3,
|
||||
createdAt: 100,
|
||||
updatedAt: 200,
|
||||
}),
|
||||
leaseToken: "lease-token",
|
||||
workerId: "worker-2",
|
||||
},
|
||||
"packages:failed": {
|
||||
_id: "packages:failed",
|
||||
name: "@acme/failed-plugin",
|
||||
displayName: "Failed Plugin",
|
||||
normalizedName: "@acme/failed-plugin",
|
||||
family: "code-plugin",
|
||||
ownerUserId: "users:owner",
|
||||
latestReleaseId: "packageReleases:failed",
|
||||
},
|
||||
"packageReleases:failed": {
|
||||
_id: "packageReleases:failed",
|
||||
packageId: "packages:failed",
|
||||
version: "1.0.0",
|
||||
createdAt: 100,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await failJobInternalHandler(ctx, {
|
||||
jobId: "securityScanJobs:failed",
|
||||
leaseToken: "lease-token",
|
||||
error: "Codex worker failed Authorization: Bearer sk-secret",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, retry: false });
|
||||
expect(patches).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "securityScanJobs:failed",
|
||||
patch: expect.objectContaining({ status: "failed" }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(inserts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
table: "securityScanArtifactStates",
|
||||
doc: expect.objectContaining({
|
||||
artifactKind: "plugin",
|
||||
packageId: "packages:failed",
|
||||
packageReleaseId: "packageReleases:failed",
|
||||
scanJobStatus: "failed",
|
||||
failureStatus: "failed",
|
||||
clawScanVerdict: "failed",
|
||||
lastScanWorkerId: "worker-2",
|
||||
lastScanAttempts: 3,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
table: "securityScanHourlyRollupEvents",
|
||||
doc: expect.objectContaining({
|
||||
eventKey: "securityScanJobs:failed:failed:3",
|
||||
artifactKind: "plugin",
|
||||
scanJobStatus: "failed",
|
||||
failureStatus: "failed",
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const state = inserts.find((entry) => entry.table === "securityScanArtifactStates")?.doc;
|
||||
expect(state?.lastError).not.toContain("sk-secret");
|
||||
});
|
||||
|
||||
it("refreshes failed digest evidence without erasing same-job worker metadata", async () => {
|
||||
const { ctx, patches } = makeRescanCtx({
|
||||
actorId: "users:owner",
|
||||
docs: {
|
||||
"securityScanJobs:failed": {
|
||||
...makeScanJob({
|
||||
_id: "securityScanJobs:failed",
|
||||
status: "failed",
|
||||
targetKind: "packageRelease",
|
||||
skillVersionId: undefined,
|
||||
packageReleaseId: "packageReleases:failed",
|
||||
source: "manual",
|
||||
attempts: 3,
|
||||
createdAt: 100,
|
||||
updatedAt: 200,
|
||||
}),
|
||||
lastError: "Codex worker failed",
|
||||
},
|
||||
"packages:failed": {
|
||||
_id: "packages:failed",
|
||||
name: "@acme/failed-plugin",
|
||||
displayName: "Failed Plugin",
|
||||
normalizedName: "@acme/failed-plugin",
|
||||
family: "code-plugin",
|
||||
ownerUserId: "users:owner",
|
||||
latestReleaseId: "packageReleases:failed",
|
||||
},
|
||||
"packageReleases:failed": {
|
||||
_id: "packageReleases:failed",
|
||||
packageId: "packages:failed",
|
||||
version: "1.0.0",
|
||||
createdAt: 100,
|
||||
llmAnalysis: {
|
||||
status: "error",
|
||||
summary: "ClawScan could not complete.",
|
||||
checkedAt: 300,
|
||||
},
|
||||
},
|
||||
"securityScanArtifactStates:existing": {
|
||||
_id: "securityScanArtifactStates:existing",
|
||||
_creationTime: 1,
|
||||
artifactKind: "plugin",
|
||||
artifactKey: "plugin:packages:failed",
|
||||
targetKind: "packageRelease",
|
||||
targetKey: "packageRelease:packageReleases:failed",
|
||||
packageId: "packages:failed",
|
||||
packageReleaseId: "packageReleases:failed",
|
||||
ownerUserId: "users:owner",
|
||||
name: "@acme/failed-plugin",
|
||||
displayName: "Failed Plugin",
|
||||
version: "1.0.0",
|
||||
clawScanVerdict: "failed",
|
||||
scanJobStatus: "failed",
|
||||
failureStatus: "failed",
|
||||
lastScanJobId: "securityScanJobs:failed",
|
||||
lastScanWorkerId: "worker-2",
|
||||
lastScanAttempts: 3,
|
||||
createdAt: 200,
|
||||
updatedAt: 200,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
refreshJobDigestInternalHandler(ctx, { jobId: "securityScanJobs:failed" }),
|
||||
).resolves.toMatchObject({ synced: true, artifactKind: "plugin" });
|
||||
|
||||
expect(patches).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "securityScanArtifactStates:existing",
|
||||
patch: expect.objectContaining({
|
||||
clawScanStatus: "error",
|
||||
clawScanSummary: "ClawScan could not complete.",
|
||||
lastScanWorkerId: "worker-2",
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("caps SkillSpector findings before storing completed scan results", async () => {
|
||||
vi.stubEnv("SECURITY_SCAN_WORKER_TOKEN", "worker-secret");
|
||||
const longSnippet = "sensitive SkillSpector artifact text ".repeat(200);
|
||||
@@ -1080,6 +1642,13 @@ describe("securityScan", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(runMutation).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
jobId: "securityScanJobs:1",
|
||||
}),
|
||||
);
|
||||
const llmAnalysis = runMutation.mock.calls[1]?.[1]?.llmAnalysis as
|
||||
| { findings?: string }
|
||||
| undefined;
|
||||
|
||||
+254
-25
@@ -6,7 +6,16 @@ import { action, internalMutation, internalQuery, mutation } from "./functions";
|
||||
import { assertModerator, requireUser } from "./lib/access";
|
||||
import { normalizePackageName } from "./lib/packageRegistry";
|
||||
import { assertCanManageOwnedResource } from "./lib/publishers";
|
||||
import {
|
||||
buildPluginSecurityScanArtifactState,
|
||||
buildSkillSecurityScanArtifactState,
|
||||
type SecurityScanArtifactStateFields,
|
||||
} from "./lib/securityScanDigest";
|
||||
import { sourceSkillVersionFiles } from "./lib/skillCards";
|
||||
import {
|
||||
recordSecurityScanHourlyRollupEvent,
|
||||
upsertSecurityScanArtifactState,
|
||||
} from "./securityScanDigests";
|
||||
|
||||
const MAX_PARALLEL_CODEX_SCANS = 64;
|
||||
const DEFAULT_VT_WAIT_MS = 10 * 60 * 1000;
|
||||
@@ -105,6 +114,27 @@ type EnqueuePackageReleaseScanArgs = {
|
||||
waitForVtMs?: number;
|
||||
};
|
||||
|
||||
type DigestSecurityScanJob = Pick<
|
||||
Doc<"securityScanJobs">,
|
||||
| "_id"
|
||||
| "targetKind"
|
||||
| "skillVersionId"
|
||||
| "packageReleaseId"
|
||||
| "status"
|
||||
| "source"
|
||||
| "workerId"
|
||||
| "attempts"
|
||||
| "createdAt"
|
||||
| "updatedAt"
|
||||
| "completedAt"
|
||||
| "lastError"
|
||||
>;
|
||||
|
||||
type SecurityScanDigestEvent = {
|
||||
eventKey: string;
|
||||
occurredAt: number;
|
||||
};
|
||||
|
||||
const llmAgenticRiskEvidenceValidator = v.object({
|
||||
path: v.string(),
|
||||
snippet: v.string(),
|
||||
@@ -204,6 +234,7 @@ const internalRefs = internal as unknown as {
|
||||
enqueueSkillVersionScanInternal: unknown;
|
||||
failJobInternal: unknown;
|
||||
getJobTargetInternal: unknown;
|
||||
refreshJobDigestInternal: unknown;
|
||||
succeedJobInternal: unknown;
|
||||
};
|
||||
skills: {
|
||||
@@ -251,7 +282,12 @@ function publicWorkerErrorDetail(error: string) {
|
||||
return error
|
||||
.replace(/https?:\/\/[^\s"')<>]+/g, "[redacted-url]")
|
||||
.replace(
|
||||
/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{12,}/gi,
|
||||
/\b(authorization)(["']?\s*[:=]\s*["']?)(Bearer|Basic)\s+[^\s"',}]+/gi,
|
||||
(_match, key: string, separator: string, scheme: string) =>
|
||||
`${key}${separator}${scheme} [redacted-secret]`,
|
||||
)
|
||||
.replace(
|
||||
/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{6,}/gi,
|
||||
(_match, scheme: string) => `${scheme} [redacted-secret]`,
|
||||
)
|
||||
.replace(
|
||||
@@ -359,6 +395,85 @@ function hasArtifactBackedLlmAnalysis(analysis: ExistingLlmAnalysis | undefined)
|
||||
);
|
||||
}
|
||||
|
||||
function securityScanDigestEventKey(
|
||||
jobId: Id<"securityScanJobs">,
|
||||
event: string,
|
||||
discriminator: string | number,
|
||||
) {
|
||||
return `${jobId}:${event}:${discriminator}`;
|
||||
}
|
||||
|
||||
function hourlyDimensionsFromState(state: SecurityScanArtifactStateFields) {
|
||||
return {
|
||||
artifactKind: state.artifactKind,
|
||||
clawScanVerdict: state.clawScanVerdict,
|
||||
scanJobStatus: state.scanJobStatus,
|
||||
failureStatus: state.failureStatus,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildCurrentSecurityScanArtifactStateForJob(
|
||||
ctx: MutationCtx,
|
||||
job: DigestSecurityScanJob,
|
||||
now: number,
|
||||
): Promise<SecurityScanArtifactStateFields | null> {
|
||||
if (job.targetKind === "skillVersion" && job.skillVersionId) {
|
||||
const version = await ctx.db.get(job.skillVersionId);
|
||||
if (!version || version.softDeletedAt) return null;
|
||||
|
||||
const skill = await ctx.db.get(version.skillId);
|
||||
if (!skill || skill.softDeletedAt || skill.latestVersionId !== version._id) return null;
|
||||
|
||||
return buildSkillSecurityScanArtifactState({ skill, version, scanJob: job, now });
|
||||
}
|
||||
|
||||
if (job.targetKind === "packageRelease" && job.packageReleaseId) {
|
||||
const release = await ctx.db.get(job.packageReleaseId);
|
||||
if (!release || release.softDeletedAt) return null;
|
||||
|
||||
const pkg = await ctx.db.get(release.packageId);
|
||||
if (!pkg || pkg.softDeletedAt || pkg.family === "skill" || pkg.latestReleaseId !== release._id)
|
||||
return null;
|
||||
|
||||
return buildPluginSecurityScanArtifactState({ pkg, release, scanJob: job, now });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function syncSecurityScanDigestForJob(
|
||||
ctx: MutationCtx,
|
||||
job: DigestSecurityScanJob,
|
||||
event?: SecurityScanDigestEvent,
|
||||
) {
|
||||
const now = Date.now();
|
||||
const builtState = await buildCurrentSecurityScanArtifactStateForJob(ctx, job, now);
|
||||
if (!builtState) return { synced: false as const };
|
||||
|
||||
let state = builtState;
|
||||
if (!state.lastScanWorkerId) {
|
||||
const existing = await ctx.db
|
||||
.query("securityScanArtifactStates")
|
||||
.withIndex("by_artifact_kind_and_artifact_key", (q) =>
|
||||
q.eq("artifactKind", state.artifactKind).eq("artifactKey", state.artifactKey),
|
||||
)
|
||||
.unique();
|
||||
if (existing && existing.lastScanJobId === state.lastScanJobId && existing.lastScanWorkerId) {
|
||||
state = { ...state, lastScanWorkerId: existing.lastScanWorkerId };
|
||||
}
|
||||
}
|
||||
|
||||
await upsertSecurityScanArtifactState(ctx, state);
|
||||
if (event) {
|
||||
await recordSecurityScanHourlyRollupEvent(ctx, {
|
||||
eventKey: event.eventKey,
|
||||
occurredAt: event.occurredAt,
|
||||
dimensions: hourlyDimensionsFromState(state),
|
||||
});
|
||||
}
|
||||
return { synced: true as const, artifactKind: state.artifactKind };
|
||||
}
|
||||
|
||||
function normalizeLimit(limit: number | undefined) {
|
||||
return Math.max(
|
||||
1,
|
||||
@@ -686,18 +801,28 @@ async function enqueueSkillVersionScan(ctx: MutationCtx, args: EnqueueSkillVersi
|
||||
.collect();
|
||||
const active = existing.find((job) => job.status === "queued" || job.status === "running");
|
||||
if (active) {
|
||||
await ctx.db.patch(active._id, {
|
||||
const updatedJob = {
|
||||
...active,
|
||||
source: args.source,
|
||||
priority: Math.max(active.priority, args.priority ?? 0),
|
||||
hasMaliciousSignal,
|
||||
waitForVtUntil: Math.min(active.waitForVtUntil, waitForVtUntil),
|
||||
nextRunAt: Math.min(active.nextRunAt, nextRunAt),
|
||||
updatedAt: now,
|
||||
};
|
||||
await ctx.db.patch(active._id, {
|
||||
source: updatedJob.source,
|
||||
priority: updatedJob.priority,
|
||||
hasMaliciousSignal: updatedJob.hasMaliciousSignal,
|
||||
waitForVtUntil: updatedJob.waitForVtUntil,
|
||||
nextRunAt: updatedJob.nextRunAt,
|
||||
updatedAt: now,
|
||||
});
|
||||
await syncSecurityScanDigestForJob(ctx, updatedJob);
|
||||
return { ok: true as const, jobId: active._id, alreadyQueued: true as const };
|
||||
}
|
||||
|
||||
const jobId = await ctx.db.insert("securityScanJobs", {
|
||||
const job = {
|
||||
targetKind: "skillVersion",
|
||||
skillVersionId: args.versionId,
|
||||
status: "queued",
|
||||
@@ -709,7 +834,18 @@ async function enqueueSkillVersionScan(ctx: MutationCtx, args: EnqueueSkillVersi
|
||||
attempts: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
} satisfies Omit<DigestSecurityScanJob, "_id"> & {
|
||||
priority: number;
|
||||
hasMaliciousSignal: boolean;
|
||||
waitForVtUntil: number;
|
||||
nextRunAt: number;
|
||||
};
|
||||
const jobId = await ctx.db.insert("securityScanJobs", job);
|
||||
await syncSecurityScanDigestForJob(
|
||||
ctx,
|
||||
{ ...job, _id: jobId },
|
||||
{ eventKey: securityScanDigestEventKey(jobId, "queued", "created"), occurredAt: now },
|
||||
);
|
||||
return { ok: true as const, jobId, alreadyQueued: false as const };
|
||||
}
|
||||
|
||||
@@ -739,18 +875,28 @@ async function enqueuePackageReleaseScan(ctx: MutationCtx, args: EnqueuePackageR
|
||||
.collect();
|
||||
const active = existing.find((job) => job.status === "queued" || job.status === "running");
|
||||
if (active) {
|
||||
await ctx.db.patch(active._id, {
|
||||
const updatedJob = {
|
||||
...active,
|
||||
source: args.source,
|
||||
priority: Math.max(active.priority, args.priority ?? 0),
|
||||
hasMaliciousSignal,
|
||||
waitForVtUntil: Math.min(active.waitForVtUntil, waitForVtUntil),
|
||||
nextRunAt: Math.min(active.nextRunAt, nextRunAt),
|
||||
updatedAt: now,
|
||||
};
|
||||
await ctx.db.patch(active._id, {
|
||||
source: updatedJob.source,
|
||||
priority: updatedJob.priority,
|
||||
hasMaliciousSignal: updatedJob.hasMaliciousSignal,
|
||||
waitForVtUntil: updatedJob.waitForVtUntil,
|
||||
nextRunAt: updatedJob.nextRunAt,
|
||||
updatedAt: now,
|
||||
});
|
||||
await syncSecurityScanDigestForJob(ctx, updatedJob);
|
||||
return { ok: true as const, jobId: active._id, alreadyQueued: true as const };
|
||||
}
|
||||
|
||||
const jobId = await ctx.db.insert("securityScanJobs", {
|
||||
const job = {
|
||||
targetKind: "packageRelease",
|
||||
packageReleaseId: args.releaseId,
|
||||
status: "queued",
|
||||
@@ -762,7 +908,18 @@ async function enqueuePackageReleaseScan(ctx: MutationCtx, args: EnqueuePackageR
|
||||
attempts: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
} satisfies Omit<DigestSecurityScanJob, "_id"> & {
|
||||
priority: number;
|
||||
hasMaliciousSignal: boolean;
|
||||
waitForVtUntil: number;
|
||||
nextRunAt: number;
|
||||
};
|
||||
const jobId = await ctx.db.insert("securityScanJobs", job);
|
||||
await syncSecurityScanDigestForJob(
|
||||
ctx,
|
||||
{ ...job, _id: jobId },
|
||||
{ eventKey: securityScanDigestEventKey(jobId, "queued", "created"), occurredAt: now },
|
||||
);
|
||||
return { ok: true as const, jobId, alreadyQueued: false as const };
|
||||
}
|
||||
|
||||
@@ -918,13 +1075,30 @@ export const claimQueuedJobsInternal = internalMutation({
|
||||
.take(MAX_PARALLEL_CODEX_SCANS * 4);
|
||||
for (const job of running) {
|
||||
if ((job.leaseExpiresAt ?? 0) <= now) {
|
||||
await ctx.db.patch(job._id, {
|
||||
status: "queued",
|
||||
const requeuedJob = {
|
||||
...job,
|
||||
status: "queued" as const,
|
||||
leaseToken: undefined,
|
||||
leaseExpiresAt: undefined,
|
||||
workerId: undefined,
|
||||
nextRunAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await ctx.db.patch(job._id, {
|
||||
status: requeuedJob.status,
|
||||
leaseToken: requeuedJob.leaseToken,
|
||||
leaseExpiresAt: requeuedJob.leaseExpiresAt,
|
||||
workerId: requeuedJob.workerId,
|
||||
nextRunAt: requeuedJob.nextRunAt,
|
||||
updatedAt: now,
|
||||
});
|
||||
await syncSecurityScanDigestForJob(ctx, requeuedJob, {
|
||||
eventKey: securityScanDigestEventKey(
|
||||
job._id,
|
||||
"queued",
|
||||
`lease-expired:${job.leaseToken ?? job.updatedAt}`,
|
||||
),
|
||||
occurredAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -976,23 +1150,29 @@ export const claimQueuedJobsInternal = internalMutation({
|
||||
const claimed = [];
|
||||
for (const job of ready) {
|
||||
const leaseToken = crypto.randomUUID();
|
||||
await ctx.db.patch(job._id, {
|
||||
status: "running",
|
||||
attempts: job.attempts + 1,
|
||||
leaseToken,
|
||||
leaseExpiresAt: now + leaseMs,
|
||||
workerId: args.workerId,
|
||||
lastError: undefined,
|
||||
updatedAt: now,
|
||||
});
|
||||
claimed.push({
|
||||
const claimedJob = {
|
||||
...job,
|
||||
status: "running" as const,
|
||||
attempts: job.attempts + 1,
|
||||
leaseToken,
|
||||
leaseExpiresAt: now + leaseMs,
|
||||
workerId: args.workerId,
|
||||
updatedAt: now,
|
||||
};
|
||||
await ctx.db.patch(job._id, {
|
||||
status: claimedJob.status,
|
||||
attempts: claimedJob.attempts,
|
||||
leaseToken: claimedJob.leaseToken,
|
||||
leaseExpiresAt: claimedJob.leaseExpiresAt,
|
||||
workerId: claimedJob.workerId,
|
||||
lastError: undefined,
|
||||
updatedAt: now,
|
||||
});
|
||||
await syncSecurityScanDigestForJob(ctx, claimedJob, {
|
||||
eventKey: securityScanDigestEventKey(job._id, "running", claimedJob.attempts),
|
||||
occurredAt: now,
|
||||
});
|
||||
claimed.push(claimedJob);
|
||||
}
|
||||
return claimed;
|
||||
},
|
||||
@@ -1027,6 +1207,17 @@ export const getJobTargetInternal = internalQuery({
|
||||
},
|
||||
});
|
||||
|
||||
export const refreshJobDigestInternal = internalMutation({
|
||||
args: {
|
||||
jobId: v.id("securityScanJobs"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const job = await ctx.db.get(args.jobId);
|
||||
if (!job) return { synced: false as const };
|
||||
return await syncSecurityScanDigestForJob(ctx, job);
|
||||
},
|
||||
});
|
||||
|
||||
export const succeedJobInternal = internalMutation({
|
||||
args: {
|
||||
jobId: v.id("securityScanJobs"),
|
||||
@@ -1037,13 +1228,26 @@ export const succeedJobInternal = internalMutation({
|
||||
const job = await ctx.db.get(args.jobId);
|
||||
if (!job || job.leaseToken !== args.leaseToken) throw new ConvexError("Lease mismatch");
|
||||
const now = Date.now();
|
||||
await ctx.db.patch(args.jobId, {
|
||||
status: "succeeded",
|
||||
const updatedJob = {
|
||||
...job,
|
||||
status: "succeeded" as const,
|
||||
runId: args.runId,
|
||||
completedAt: now,
|
||||
leaseToken: undefined,
|
||||
leaseExpiresAt: undefined,
|
||||
updatedAt: now,
|
||||
};
|
||||
await ctx.db.patch(args.jobId, {
|
||||
status: updatedJob.status,
|
||||
runId: updatedJob.runId,
|
||||
completedAt: updatedJob.completedAt,
|
||||
leaseToken: updatedJob.leaseToken,
|
||||
leaseExpiresAt: updatedJob.leaseExpiresAt,
|
||||
updatedAt: now,
|
||||
});
|
||||
await syncSecurityScanDigestForJob(ctx, updatedJob, {
|
||||
eventKey: securityScanDigestEventKey(args.jobId, "succeeded", args.runId ?? job.attempts),
|
||||
occurredAt: now,
|
||||
});
|
||||
return { ok: true as const };
|
||||
},
|
||||
@@ -1060,15 +1264,32 @@ export const failJobInternal = internalMutation({
|
||||
if (!job || job.leaseToken !== args.leaseToken) throw new ConvexError("Lease mismatch");
|
||||
const now = Date.now();
|
||||
const retry = job.attempts < MAX_ATTEMPTS;
|
||||
await ctx.db.patch(args.jobId, {
|
||||
status: retry ? "queued" : "failed",
|
||||
lastError: args.error.slice(0, 2000),
|
||||
nextRunAt: retry ? now + Math.min(30 * 60 * 1000, 2 ** job.attempts * 60_000) : job.nextRunAt,
|
||||
const lastError = publicWorkerErrorDetail(args.error).slice(0, 2000);
|
||||
const nextRunAt = retry
|
||||
? now + Math.min(30 * 60 * 1000, 2 ** job.attempts * 60_000)
|
||||
: job.nextRunAt;
|
||||
const updatedJob = {
|
||||
...job,
|
||||
status: retry ? ("queued" as const) : ("failed" as const),
|
||||
lastError,
|
||||
nextRunAt,
|
||||
leaseToken: undefined,
|
||||
leaseExpiresAt: undefined,
|
||||
updatedAt: now,
|
||||
};
|
||||
await ctx.db.patch(args.jobId, {
|
||||
status: updatedJob.status,
|
||||
lastError,
|
||||
nextRunAt,
|
||||
leaseToken: updatedJob.leaseToken,
|
||||
leaseExpiresAt: updatedJob.leaseExpiresAt,
|
||||
workerId: undefined,
|
||||
updatedAt: now,
|
||||
});
|
||||
await syncSecurityScanDigestForJob(ctx, updatedJob, {
|
||||
eventKey: securityScanDigestEventKey(args.jobId, retry ? "retry" : "failed", job.attempts),
|
||||
occurredAt: now,
|
||||
});
|
||||
return { ok: true as const, retry };
|
||||
},
|
||||
});
|
||||
@@ -1255,6 +1476,7 @@ export const failCodexScanJob = action({
|
||||
);
|
||||
|
||||
if (!result.retry) {
|
||||
let wroteFailureAnalysis = false;
|
||||
const target = await runQueryRef<JobTarget | null>(
|
||||
ctx,
|
||||
internalRefs.securityScan.getJobTargetInternal,
|
||||
@@ -1271,6 +1493,7 @@ export const failCodexScanJob = action({
|
||||
moderationMode: "preserve",
|
||||
llmAnalysis,
|
||||
});
|
||||
wroteFailureAnalysis = true;
|
||||
}
|
||||
} else if (target.job.targetKind === "packageRelease" && target.release) {
|
||||
if (!hasArtifactBackedLlmAnalysis(target.release.llmAnalysis)) {
|
||||
@@ -1278,9 +1501,15 @@ export const failCodexScanJob = action({
|
||||
releaseId: target.release._id,
|
||||
llmAnalysis,
|
||||
});
|
||||
wroteFailureAnalysis = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (wroteFailureAnalysis) {
|
||||
await runMutationRef(ctx, internalRefs.securityScan.refreshJobDigestInternal, {
|
||||
jobId: args.jobId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -0,0 +1,742 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getStaffSecurityScanArtifact,
|
||||
getStaffSecurityScanOverview,
|
||||
listStaffSecurityScanArtifacts,
|
||||
} from "./securityScanDigests";
|
||||
|
||||
vi.mock("@convex-dev/auth/server", () => ({
|
||||
getAuthUserId: vi.fn(),
|
||||
}));
|
||||
|
||||
type WrappedHandler<TArgs, TResult = unknown> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
|
||||
};
|
||||
|
||||
type FakeRow = Record<string, unknown> & {
|
||||
_id: string;
|
||||
_creationTime: number;
|
||||
};
|
||||
|
||||
type FakeTable =
|
||||
| "users"
|
||||
| "skills"
|
||||
| "skillVersions"
|
||||
| "packages"
|
||||
| "packageReleases"
|
||||
| "securityScanJobs"
|
||||
| "securityScanArtifactStates"
|
||||
| "securityScanCurrentRollups"
|
||||
| "securityScanHourlyRollups";
|
||||
|
||||
type OverviewResult = {
|
||||
window: {
|
||||
hours: number;
|
||||
rows: FakeRow[];
|
||||
totalsByKind: Record<
|
||||
string,
|
||||
{
|
||||
total: number;
|
||||
byScanJobStatus: Record<string, number>;
|
||||
}
|
||||
>;
|
||||
};
|
||||
current: Record<
|
||||
string,
|
||||
{
|
||||
totals: {
|
||||
total: number;
|
||||
byVerdict: Record<string, number>;
|
||||
byScanJobStatus: Record<string, number>;
|
||||
byFailureStatus: Record<string, number>;
|
||||
};
|
||||
rollups: Array<Record<string, unknown>>;
|
||||
}
|
||||
>;
|
||||
failed: {
|
||||
items: FakeRow[];
|
||||
limit: number;
|
||||
};
|
||||
};
|
||||
|
||||
type ArtifactListResult = {
|
||||
items: FakeRow[];
|
||||
nextCursor: string | null;
|
||||
done: boolean;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
type ArtifactDetailResult = {
|
||||
found: boolean;
|
||||
artifactKind: "skill" | "plugin";
|
||||
reason?: string;
|
||||
state?: FakeRow | null;
|
||||
scanJob?: Record<string, unknown> | null;
|
||||
evidence?: {
|
||||
clawScan: Record<string, unknown>;
|
||||
skillSpector: {
|
||||
issueCount?: number;
|
||||
issues: unknown[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const getStaffSecurityScanOverviewHandler = (
|
||||
getStaffSecurityScanOverview as unknown as WrappedHandler<
|
||||
{ artifactKind?: "skill" | "plugin"; windowHours?: number; failedLimit?: number },
|
||||
OverviewResult
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const listStaffSecurityScanArtifactsHandler = (
|
||||
listStaffSecurityScanArtifacts as unknown as WrappedHandler<
|
||||
{
|
||||
artifactKind: "skill" | "plugin";
|
||||
cursor?: string | null;
|
||||
limit?: number;
|
||||
clawScanVerdict?: string;
|
||||
scanJobStatus?: string;
|
||||
failureStatus?: string;
|
||||
clawScanPrimaryCategoryKey?: string;
|
||||
},
|
||||
ArtifactListResult
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const getStaffSecurityScanArtifactHandler = (
|
||||
getStaffSecurityScanArtifact as unknown as WrappedHandler<
|
||||
{ skillSlug?: string; packageName?: string },
|
||||
ArtifactDetailResult
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const NOW = Date.UTC(2026, 4, 26, 12, 30, 0);
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
class FakeDb {
|
||||
readonly tables: Record<FakeTable, FakeRow[]>;
|
||||
|
||||
constructor(seed: Partial<Record<FakeTable, FakeRow[]>> = {}) {
|
||||
this.tables = {
|
||||
users: [],
|
||||
skills: [],
|
||||
skillVersions: [],
|
||||
packages: [],
|
||||
packageReleases: [],
|
||||
securityScanJobs: [],
|
||||
securityScanArtifactStates: [],
|
||||
securityScanCurrentRollups: [],
|
||||
securityScanHourlyRollups: [],
|
||||
...seed,
|
||||
};
|
||||
}
|
||||
|
||||
async get(id: string) {
|
||||
return (
|
||||
Object.values(this.tables)
|
||||
.flat()
|
||||
.find((tableRow) => tableRow._id === id) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
query(table: string) {
|
||||
const tableName = table as FakeTable;
|
||||
if (!this.tables[tableName]) throw new Error(`Unexpected table ${table}`);
|
||||
|
||||
const filters: Array<
|
||||
{ op: "eq"; field: string; value: unknown } | { op: "gte"; field: string; value: number }
|
||||
> = [];
|
||||
let indexName = "";
|
||||
let orderDirection: "asc" | "desc" | null = null;
|
||||
|
||||
const range = {
|
||||
eq(field: string, value: unknown) {
|
||||
filters.push({ op: "eq", field, value });
|
||||
return range;
|
||||
},
|
||||
gte(field: string, value: number) {
|
||||
filters.push({ op: "gte", field, value });
|
||||
return range;
|
||||
},
|
||||
};
|
||||
|
||||
const select = () => {
|
||||
const rows = this.tables[tableName].filter((tableRow) =>
|
||||
filters.every((filter) => {
|
||||
if (filter.op === "eq") return tableRow[filter.field] === filter.value;
|
||||
const value = tableRow[filter.field];
|
||||
return typeof value === "number" && value >= filter.value;
|
||||
}),
|
||||
);
|
||||
if (!orderDirection) return rows;
|
||||
const orderField = indexName.includes("bucket_start_ms") ? "bucketStartMs" : "updatedAt";
|
||||
return [...rows].sort((a, b) => {
|
||||
const left = typeof a[orderField] === "number" ? a[orderField] : 0;
|
||||
const right = typeof b[orderField] === "number" ? b[orderField] : 0;
|
||||
return orderDirection === "desc" ? right - left : left - right;
|
||||
});
|
||||
};
|
||||
|
||||
const queryApi = {
|
||||
withIndex(name: string, buildRange: (q: typeof range) => unknown) {
|
||||
indexName = name;
|
||||
buildRange(range);
|
||||
return queryApi;
|
||||
},
|
||||
order(direction: "asc" | "desc") {
|
||||
orderDirection = direction;
|
||||
return queryApi;
|
||||
},
|
||||
async unique() {
|
||||
const matches = select();
|
||||
if (matches.length > 1) throw new Error(`Expected unique ${tableName} row`);
|
||||
return matches[0] ?? null;
|
||||
},
|
||||
async take(limit: number) {
|
||||
return select().slice(0, limit);
|
||||
},
|
||||
async paginate(opts: { cursor: string | null; numItems: number }) {
|
||||
const offset = opts.cursor ? Number(opts.cursor) : 0;
|
||||
const matches = select();
|
||||
const page = matches.slice(offset, offset + opts.numItems);
|
||||
const nextOffset = offset + page.length;
|
||||
const isDone = nextOffset >= matches.length;
|
||||
return {
|
||||
page,
|
||||
isDone,
|
||||
continueCursor: isDone ? null : String(nextOffset),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return queryApi;
|
||||
}
|
||||
}
|
||||
|
||||
function fakeRow(_id: string, fields: Record<string, unknown>): FakeRow {
|
||||
return {
|
||||
_id,
|
||||
_creationTime: 1,
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
|
||||
function user(id: string, role: "admin" | "moderator" | "user") {
|
||||
return fakeRow(`users:${id}`, { role });
|
||||
}
|
||||
|
||||
function currentRollup(
|
||||
fields: Record<string, unknown> & {
|
||||
artifactKind: "skill" | "plugin";
|
||||
rollupKind: string;
|
||||
categoryKey: string;
|
||||
clawScanVerdict: string;
|
||||
scanJobStatus: string;
|
||||
},
|
||||
) {
|
||||
return fakeRow(
|
||||
`securityScanCurrentRollups:${fields.artifactKind}:${fields.rollupKind}:${fields.categoryKey}:${fields.clawScanVerdict}:${fields.scanJobStatus}`,
|
||||
{
|
||||
categoryLabel: undefined,
|
||||
updatedAt: NOW - 1_000,
|
||||
...fields,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function hourlyRollup(id: string, fields: Record<string, unknown>) {
|
||||
return fakeRow(`securityScanHourlyRollups:${id}`, {
|
||||
updatedAt: NOW - 1_000,
|
||||
...fields,
|
||||
});
|
||||
}
|
||||
|
||||
function artifactState(id: string, fields: Record<string, unknown>) {
|
||||
const artifactKind = fields.artifactKind === "plugin" ? "plugin" : "skill";
|
||||
return fakeRow(`securityScanArtifactStates:${id}`, {
|
||||
artifactKind,
|
||||
targetKind: artifactKind === "plugin" ? "packageRelease" : "skillVersion",
|
||||
artifactKey: `${artifactKind}:${id}`,
|
||||
targetKey: `${artifactKind === "plugin" ? "packageRelease" : "skillVersion"}:${id}`,
|
||||
ownerUserId: "users:owner",
|
||||
displayName: `Artifact ${id}`,
|
||||
clawScanVerdict: "pass",
|
||||
scanJobStatus: "succeeded",
|
||||
failureStatus: "none",
|
||||
createdAt: NOW - 10_000,
|
||||
updatedAt: NOW - 1_000,
|
||||
...fields,
|
||||
});
|
||||
}
|
||||
|
||||
function staffCtx(seed: Partial<Record<FakeTable, FakeRow[]>> = {}) {
|
||||
const db = new FakeDb({
|
||||
users: [user("admin", "admin"), user("moderator", "moderator"), user("reader", "user")],
|
||||
...seed,
|
||||
});
|
||||
return { db, ctx: { db } };
|
||||
}
|
||||
|
||||
function authenticate(userId: string | null) {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue(userId as never);
|
||||
}
|
||||
|
||||
function seedOverviewRows() {
|
||||
const failedSkill = artifactState("failed-skill", {
|
||||
artifactKind: "skill",
|
||||
artifactKey: "skill:failed",
|
||||
displayName: "Failed Skill",
|
||||
clawScanVerdict: "failed",
|
||||
scanJobStatus: "failed",
|
||||
failureStatus: "failed",
|
||||
lastError: "Worker timeout",
|
||||
updatedAt: NOW - 500,
|
||||
});
|
||||
const failedPlugin = artifactState("failed-plugin", {
|
||||
artifactKind: "plugin",
|
||||
artifactKey: "plugin:failed",
|
||||
displayName: "Failed Plugin",
|
||||
clawScanVerdict: "failed",
|
||||
scanJobStatus: "failed",
|
||||
failureStatus: "failed",
|
||||
lastError: "Worker crashed",
|
||||
updatedAt: NOW - 300,
|
||||
});
|
||||
|
||||
return {
|
||||
securityScanArtifactStates: [failedSkill, failedPlugin],
|
||||
securityScanCurrentRollups: [
|
||||
currentRollup({
|
||||
artifactKind: "skill",
|
||||
rollupKind: "all",
|
||||
categoryKey: "all",
|
||||
clawScanVerdict: "pass",
|
||||
scanJobStatus: "succeeded",
|
||||
failureStatus: "none",
|
||||
count: 2,
|
||||
}),
|
||||
currentRollup({
|
||||
artifactKind: "skill",
|
||||
rollupKind: "all",
|
||||
categoryKey: "all",
|
||||
clawScanVerdict: "malicious",
|
||||
scanJobStatus: "succeeded",
|
||||
failureStatus: "none",
|
||||
count: 1,
|
||||
}),
|
||||
currentRollup({
|
||||
artifactKind: "skill",
|
||||
rollupKind: "all",
|
||||
categoryKey: "all",
|
||||
clawScanVerdict: "pending",
|
||||
scanJobStatus: "queued",
|
||||
failureStatus: "none",
|
||||
count: 4,
|
||||
}),
|
||||
currentRollup({
|
||||
artifactKind: "skill",
|
||||
rollupKind: "all",
|
||||
categoryKey: "all",
|
||||
clawScanVerdict: "unknown",
|
||||
scanJobStatus: "running",
|
||||
failureStatus: "none",
|
||||
count: 3,
|
||||
}),
|
||||
currentRollup({
|
||||
artifactKind: "skill",
|
||||
rollupKind: "all",
|
||||
categoryKey: "all",
|
||||
clawScanVerdict: "failed",
|
||||
scanJobStatus: "failed",
|
||||
failureStatus: "failed",
|
||||
count: 1,
|
||||
}),
|
||||
currentRollup({
|
||||
artifactKind: "skill",
|
||||
rollupKind: "clawscanCategory",
|
||||
categoryKey: "permission_boundary",
|
||||
categoryLabel: "Permission boundary",
|
||||
clawScanVerdict: "malicious",
|
||||
scanJobStatus: "succeeded",
|
||||
failureStatus: "none",
|
||||
count: 1,
|
||||
}),
|
||||
currentRollup({
|
||||
artifactKind: "plugin",
|
||||
rollupKind: "all",
|
||||
categoryKey: "all",
|
||||
clawScanVerdict: "pass",
|
||||
scanJobStatus: "succeeded",
|
||||
failureStatus: "none",
|
||||
count: 3,
|
||||
}),
|
||||
currentRollup({
|
||||
artifactKind: "plugin",
|
||||
rollupKind: "all",
|
||||
categoryKey: "all",
|
||||
clawScanVerdict: "failed",
|
||||
scanJobStatus: "failed",
|
||||
failureStatus: "failed",
|
||||
count: 1,
|
||||
}),
|
||||
],
|
||||
securityScanHourlyRollups: [
|
||||
hourlyRollup("recent-pass", {
|
||||
bucketStartMs: NOW - HOUR_MS,
|
||||
artifactKind: "skill",
|
||||
clawScanVerdict: "pass",
|
||||
scanJobStatus: "succeeded",
|
||||
failureStatus: "none",
|
||||
count: 3,
|
||||
}),
|
||||
hourlyRollup("recent-queued", {
|
||||
bucketStartMs: NOW - 2 * HOUR_MS,
|
||||
artifactKind: "skill",
|
||||
clawScanVerdict: "pending",
|
||||
scanJobStatus: "queued",
|
||||
failureStatus: "none",
|
||||
count: 2,
|
||||
}),
|
||||
hourlyRollup("recent-plugin-failed", {
|
||||
bucketStartMs: NOW - 3 * HOUR_MS,
|
||||
artifactKind: "plugin",
|
||||
clawScanVerdict: "failed",
|
||||
scanJobStatus: "failed",
|
||||
failureStatus: "failed",
|
||||
count: 1,
|
||||
}),
|
||||
hourlyRollup("old-skill", {
|
||||
bucketStartMs: NOW - 48 * HOUR_MS,
|
||||
artifactKind: "skill",
|
||||
clawScanVerdict: "malicious",
|
||||
scanJobStatus: "succeeded",
|
||||
failureStatus: "none",
|
||||
count: 99,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("staff security scan digest APIs", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.mocked(getAuthUserId).mockReset();
|
||||
});
|
||||
|
||||
it("allows admins and moderators to read overview data but rejects ordinary users", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(NOW);
|
||||
const { ctx } = staffCtx();
|
||||
|
||||
authenticate(null);
|
||||
await expect(getStaffSecurityScanOverviewHandler(ctx, {})).rejects.toThrow("Unauthorized");
|
||||
|
||||
authenticate("users:reader");
|
||||
await expect(getStaffSecurityScanOverviewHandler(ctx, {})).rejects.toThrow("Forbidden");
|
||||
|
||||
authenticate("users:moderator");
|
||||
await expect(getStaffSecurityScanOverviewHandler(ctx, {})).resolves.toMatchObject({
|
||||
window: { hours: 24 },
|
||||
});
|
||||
|
||||
authenticate("users:admin");
|
||||
await expect(getStaffSecurityScanOverviewHandler(ctx, {})).resolves.toMatchObject({
|
||||
window: { hours: 24 },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns current rollups, percentage bases, recent window rows, and failed samples", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(NOW);
|
||||
authenticate("users:moderator");
|
||||
const { ctx } = staffCtx(seedOverviewRows());
|
||||
|
||||
const result = await getStaffSecurityScanOverviewHandler(ctx, {
|
||||
windowHours: 24,
|
||||
failedLimit: 1,
|
||||
});
|
||||
|
||||
expect(result.current.skill.totals).toMatchObject({
|
||||
total: 11,
|
||||
byVerdict: expect.objectContaining({
|
||||
pass: 2,
|
||||
malicious: 1,
|
||||
pending: 4,
|
||||
failed: 1,
|
||||
}),
|
||||
byScanJobStatus: expect.objectContaining({
|
||||
queued: 4,
|
||||
running: 3,
|
||||
failed: 1,
|
||||
}),
|
||||
byFailureStatus: expect.objectContaining({
|
||||
failed: 1,
|
||||
}),
|
||||
});
|
||||
expect(result.current.skill.rollups).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
rollupKind: "clawscanCategory",
|
||||
categoryKey: "permission_boundary",
|
||||
count: 1,
|
||||
totalForKind: 11,
|
||||
percentageBasis: 11,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(result.window.totalsByKind.skill).toMatchObject({
|
||||
total: 5,
|
||||
byScanJobStatus: expect.objectContaining({ queued: 2, succeeded: 3 }),
|
||||
});
|
||||
expect(result.window.rows).toEqual(
|
||||
expect.not.arrayContaining([expect.objectContaining({ count: 99 })]),
|
||||
);
|
||||
expect(result.failed.items).toMatchObject([
|
||||
{
|
||||
artifactKind: "plugin",
|
||||
displayName: "Failed Plugin",
|
||||
failureStatus: "failed",
|
||||
},
|
||||
]);
|
||||
expect(result.failed.limit).toBe(1);
|
||||
});
|
||||
|
||||
it("paginates artifact rows with indexed status filters and clamps page size", async () => {
|
||||
authenticate("users:moderator");
|
||||
const first = artifactState("plugin-old-failed", {
|
||||
artifactKind: "plugin",
|
||||
displayName: "Older Failed Plugin",
|
||||
failureStatus: "failed",
|
||||
scanJobStatus: "failed",
|
||||
clawScanVerdict: "failed",
|
||||
updatedAt: NOW - 10_000,
|
||||
});
|
||||
const second = artifactState("plugin-new-failed", {
|
||||
artifactKind: "plugin",
|
||||
displayName: "Newer Failed Plugin",
|
||||
failureStatus: "failed",
|
||||
scanJobStatus: "failed",
|
||||
clawScanVerdict: "failed",
|
||||
updatedAt: NOW - 100,
|
||||
});
|
||||
const pass = artifactState("plugin-pass", {
|
||||
artifactKind: "plugin",
|
||||
displayName: "Passing Plugin",
|
||||
failureStatus: "none",
|
||||
scanJobStatus: "succeeded",
|
||||
clawScanVerdict: "pass",
|
||||
updatedAt: NOW - 50,
|
||||
});
|
||||
const { ctx } = staffCtx({
|
||||
securityScanArtifactStates: [first, second, pass],
|
||||
});
|
||||
|
||||
const firstPage = await listStaffSecurityScanArtifactsHandler(ctx, {
|
||||
artifactKind: "plugin",
|
||||
failureStatus: "failed",
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
expect(firstPage).toMatchObject({
|
||||
items: [expect.objectContaining({ displayName: "Newer Failed Plugin" })],
|
||||
nextCursor: "1",
|
||||
done: false,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const secondPage = await listStaffSecurityScanArtifactsHandler(ctx, {
|
||||
artifactKind: "plugin",
|
||||
failureStatus: "failed",
|
||||
cursor: firstPage.nextCursor,
|
||||
limit: 999,
|
||||
});
|
||||
|
||||
expect(secondPage).toMatchObject({
|
||||
items: [expect.objectContaining({ displayName: "Older Failed Plugin" })],
|
||||
nextCursor: null,
|
||||
done: true,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
await expect(
|
||||
listStaffSecurityScanArtifactsHandler(ctx, {
|
||||
artifactKind: "plugin",
|
||||
failureStatus: "failed",
|
||||
scanJobStatus: "failed",
|
||||
}),
|
||||
).rejects.toThrow("Provide at most one security scan artifact filter");
|
||||
});
|
||||
|
||||
it("looks up a skill by slug with state, evidence summary, and sanitized scan job details", async () => {
|
||||
authenticate("users:admin");
|
||||
const issues = Array.from({ length: 12 }, (_, index) => ({
|
||||
issueId: `issue-${index}`,
|
||||
severity: "high",
|
||||
explanation: "Risky tool use",
|
||||
}));
|
||||
const { ctx } = staffCtx({
|
||||
skills: [
|
||||
fakeRow("skills:demo", {
|
||||
slug: "demo-skill",
|
||||
displayName: "Demo Skill",
|
||||
ownerUserId: "users:owner",
|
||||
latestVersionId: "skillVersions:demo",
|
||||
}),
|
||||
],
|
||||
skillVersions: [
|
||||
fakeRow("skillVersions:demo", {
|
||||
skillId: "skills:demo",
|
||||
version: "1.2.3",
|
||||
createdAt: NOW - 5_000,
|
||||
llmAnalysis: {
|
||||
status: "malicious",
|
||||
verdict: "malicious",
|
||||
confidence: "high",
|
||||
summary: "Exfiltrates secrets",
|
||||
checkedAt: NOW - 1_000,
|
||||
},
|
||||
skillSpectorAnalysis: {
|
||||
status: "suspicious",
|
||||
score: 85,
|
||||
severity: "high",
|
||||
recommendation: "block",
|
||||
issueCount: issues.length,
|
||||
checkedAt: NOW - 900,
|
||||
issues,
|
||||
},
|
||||
}),
|
||||
],
|
||||
securityScanArtifactStates: [
|
||||
artifactState("demo-skill", {
|
||||
artifactKind: "skill",
|
||||
artifactKey: "skill:skills:demo",
|
||||
targetKey: "skillVersion:skillVersions:demo",
|
||||
skillId: "skills:demo",
|
||||
skillVersionId: "skillVersions:demo",
|
||||
slug: "demo-skill",
|
||||
displayName: "Demo Skill",
|
||||
version: "1.2.3",
|
||||
clawScanVerdict: "malicious",
|
||||
scanJobStatus: "succeeded",
|
||||
lastScanJobId: "securityScanJobs:demo",
|
||||
}),
|
||||
],
|
||||
securityScanJobs: [
|
||||
fakeRow("securityScanJobs:demo", {
|
||||
status: "succeeded",
|
||||
targetKind: "skillVersion",
|
||||
skillVersionId: "skillVersions:demo",
|
||||
source: "manual",
|
||||
priority: 100,
|
||||
hasMaliciousSignal: true,
|
||||
waitForVtUntil: 0,
|
||||
nextRunAt: NOW - 4_000,
|
||||
attempts: 2,
|
||||
leaseToken: "internal-secret-token",
|
||||
leaseExpiresAt: NOW - 3_000,
|
||||
workerId: "worker-a",
|
||||
runId: "run-a",
|
||||
completedAt: NOW - 2_000,
|
||||
createdAt: NOW - 4_000,
|
||||
updatedAt: NOW - 2_000,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const detail = await getStaffSecurityScanArtifactHandler(ctx, { skillSlug: "demo-skill" });
|
||||
|
||||
expect(detail).toMatchObject({
|
||||
found: true,
|
||||
artifactKind: "skill",
|
||||
state: expect.objectContaining({
|
||||
slug: "demo-skill",
|
||||
clawScanVerdict: "malicious",
|
||||
}),
|
||||
scanJob: expect.objectContaining({
|
||||
_id: "securityScanJobs:demo",
|
||||
attempts: 2,
|
||||
workerId: "worker-a",
|
||||
}),
|
||||
evidence: {
|
||||
clawScan: expect.objectContaining({
|
||||
verdict: "malicious",
|
||||
summary: "Exfiltrates secrets",
|
||||
}),
|
||||
skillSpector: expect.objectContaining({
|
||||
issueCount: 12,
|
||||
issues: expect.any(Array),
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(detail.scanJob).not.toHaveProperty("leaseToken");
|
||||
expect(detail.evidence?.skillSpector.issues).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("looks up plugins by normalized package name and returns missing artifacts cleanly", async () => {
|
||||
authenticate("users:moderator");
|
||||
const { ctx } = staffCtx({
|
||||
packages: [
|
||||
fakeRow("packages:demo-plugin", {
|
||||
name: "@openclaw/demo-plugin",
|
||||
normalizedName: "@openclaw/demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
ownerUserId: "users:owner",
|
||||
family: "code-plugin",
|
||||
latestReleaseId: "packageReleases:demo-plugin",
|
||||
}),
|
||||
],
|
||||
packageReleases: [
|
||||
fakeRow("packageReleases:demo-plugin", {
|
||||
packageId: "packages:demo-plugin",
|
||||
version: "2.0.0",
|
||||
createdAt: NOW - 5_000,
|
||||
llmAnalysis: {
|
||||
status: "clean",
|
||||
verdict: "benign",
|
||||
checkedAt: NOW - 1_000,
|
||||
},
|
||||
}),
|
||||
],
|
||||
securityScanArtifactStates: [
|
||||
artifactState("demo-plugin", {
|
||||
artifactKind: "plugin",
|
||||
artifactKey: "plugin:packages:demo-plugin",
|
||||
targetKey: "packageRelease:packageReleases:demo-plugin",
|
||||
packageId: "packages:demo-plugin",
|
||||
packageReleaseId: "packageReleases:demo-plugin",
|
||||
name: "@openclaw/demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "2.0.0",
|
||||
clawScanVerdict: "pass",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const detail = await getStaffSecurityScanArtifactHandler(ctx, {
|
||||
packageName: "@OPENCLAW/Demo-Plugin",
|
||||
});
|
||||
|
||||
expect(detail).toMatchObject({
|
||||
found: true,
|
||||
artifactKind: "plugin",
|
||||
state: expect.objectContaining({
|
||||
name: "@openclaw/demo-plugin",
|
||||
clawScanVerdict: "pass",
|
||||
}),
|
||||
evidence: {
|
||||
clawScan: expect.objectContaining({
|
||||
verdict: "benign",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
getStaffSecurityScanArtifactHandler(ctx, { skillSlug: "missing-skill" }),
|
||||
).resolves.toMatchObject({
|
||||
found: false,
|
||||
artifactKind: "skill",
|
||||
reason: "missing",
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,7 @@ Policy, API, and trust docs:
|
||||
- `docs/api.md`: public REST API overview.
|
||||
- `docs/http-api.md`: detailed HTTP API reference.
|
||||
- `docs/security-audits.md`: user-facing security audit status, risk levels, findings, and interpretation.
|
||||
- `docs/security-scan-visibility.md`: staff dashboard and `clawhub-mod` security scan overview usage.
|
||||
- `docs/moderation.md`: reports, moderation holds, hidden listings, bans, and account standing.
|
||||
|
||||
Maintainer records:
|
||||
|
||||
@@ -945,6 +945,103 @@ Response:
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/security-scans/overview`
|
||||
|
||||
Moderator/admin endpoint for current ClawScan-first scan rollups and recent
|
||||
time-window health.
|
||||
|
||||
Auth:
|
||||
|
||||
- Requires an API token for a moderator or admin user.
|
||||
|
||||
Query params:
|
||||
|
||||
- `artifactKind` (optional): `skill` or `plugin`; omit for both.
|
||||
- `windowHours` (optional): recent window size in hours.
|
||||
- `failedLimit` (optional): number of failed samples to include.
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"generatedAt": 1730000000000,
|
||||
"window": {
|
||||
"hours": 24,
|
||||
"totalsByKind": {},
|
||||
"rows": [],
|
||||
"truncated": false
|
||||
},
|
||||
"current": {},
|
||||
"failed": {
|
||||
"items": [],
|
||||
"limit": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/security-scans/artifacts`
|
||||
|
||||
Moderator/admin endpoint for paginated security scan digest rows.
|
||||
|
||||
Auth:
|
||||
|
||||
- Requires an API token for a moderator or admin user.
|
||||
|
||||
Query params:
|
||||
|
||||
- `artifactKind` (required): `skill` or `plugin`.
|
||||
- `limit` (optional): integer (1-100).
|
||||
- `cursor` (optional): pagination cursor.
|
||||
- At most one filter may be supplied:
|
||||
- `clawScanVerdict`: `pass`, `suspicious`, `malicious`, `pending`, `failed`,
|
||||
or `unknown`.
|
||||
- `scanJobStatus`: `none`, `queued`, `running`, `succeeded`, or `failed`.
|
||||
- `failureStatus`: `none` or `failed`.
|
||||
- `clawScanPrimaryCategoryKey`: ClawScan category key.
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [],
|
||||
"nextCursor": null,
|
||||
"done": true,
|
||||
"limit": 25
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/security-scans/artifact`
|
||||
|
||||
Moderator/admin endpoint for one skill or plugin drilldown.
|
||||
|
||||
Auth:
|
||||
|
||||
- Requires an API token for a moderator or admin user.
|
||||
|
||||
Query params:
|
||||
|
||||
- Exactly one of `skillSlug` or `packageName`.
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"found": true,
|
||||
"artifactKind": "skill",
|
||||
"state": {},
|
||||
"artifact": {},
|
||||
"scanJob": {},
|
||||
"evidence": {}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- ClawScan/Codex is the final verdict source.
|
||||
- SkillSpector, static analysis, VirusTotal, and worker details are supporting
|
||||
evidence for drilldown.
|
||||
- Scan-job lease tokens and other worker-only internals are not returned.
|
||||
|
||||
### `POST /api/v1/packages/reports/{reportId}/triage`
|
||||
|
||||
Moderator/admin endpoint for resolving or reopening package reports.
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
summary: "Staff guide for ClawScan-first production security scan visibility."
|
||||
read_when:
|
||||
- Checking production security scan health
|
||||
- Using the management security scan dashboard
|
||||
- Using clawhub-mod security-scans commands
|
||||
title: "Security Scan Visibility"
|
||||
sidebarTitle: "Security Scan Visibility"
|
||||
---
|
||||
|
||||
# Security Scan Visibility
|
||||
|
||||
Admins and site moderators can use the management console and `clawhub-mod` to
|
||||
see how the ClawHub security scan pipeline is doing across skills and plugins.
|
||||
Use this view for operational questions such as:
|
||||
|
||||
- how many current artifacts pass, look suspicious, are malicious, are pending,
|
||||
failed, or unknown
|
||||
- how the last 24 hours of scan events behaved
|
||||
- which scans are queued, running, or failed
|
||||
- what happened for one specific skill or plugin release
|
||||
|
||||
The staff dashboard is available at:
|
||||
|
||||
```text
|
||||
/management
|
||||
```
|
||||
|
||||
Open the Security scans section there to switch between all artifacts, skills,
|
||||
and plugins; change the recent window; inspect ClawScan categories; review
|
||||
failed scans; and drill into one skill slug or plugin package name.
|
||||
|
||||
## Source Of Truth
|
||||
|
||||
The top-level verdict is ClawScan/Codex. SkillSpector, static analysis,
|
||||
VirusTotal, and worker details are evidence that helps explain the verdict; do
|
||||
not treat them as the final verdict when reporting aggregate health.
|
||||
|
||||
| State | Meaning |
|
||||
| ------------ | ------------------------------------------------------------------ |
|
||||
| `pass` | ClawScan resolved the latest artifact without visible concerns. |
|
||||
| `suspicious` | ClawScan found concerns that deserve review before installation. |
|
||||
| `malicious` | ClawScan determined the artifact should not be installed. |
|
||||
| `pending` | ClawScan is queued/running or has not produced a final result yet. |
|
||||
| `failed` | The current scan job exhausted unsuccessfully. |
|
||||
| `unknown` | No current ClawScan result or scan job is available. |
|
||||
|
||||
Pipeline status answers a different question:
|
||||
|
||||
| Pipeline status | Meaning |
|
||||
| --------------- | -------------------------------------------- |
|
||||
| `queued` | Waiting for a worker. |
|
||||
| `running` | Claimed by a worker. |
|
||||
| `succeeded` | Worker completed and persisted scan results. |
|
||||
| `failed` | Worker failed the current job. |
|
||||
| `none` | No current worker job is attached. |
|
||||
|
||||
## CLI Commands
|
||||
|
||||
Use `clawhub-mod` when you need copyable output, JSON, or an agent-consumable
|
||||
view of the same data:
|
||||
|
||||
```bash
|
||||
bun run mod -- security-scans overview --window-hours 24
|
||||
bun run mod -- security-scans overview --window-hours 24 --json
|
||||
bun run mod -- security-scans failed --artifact-kind all --limit 25 --json
|
||||
bun run mod -- security-scans queued --artifact-kind plugin --limit 25
|
||||
bun run mod -- security-scans running --artifact-kind skill --limit 25
|
||||
bun run mod -- security-scans inspect --skill <slug> --json
|
||||
bun run mod -- security-scans inspect --plugin <package-name> --json
|
||||
```
|
||||
|
||||
For production, the default registry is `https://clawhub.ai`. For local or
|
||||
staging checks, pass the registry explicitly:
|
||||
|
||||
```bash
|
||||
bun run mod -- --registry <convex-http-url> security-scans overview
|
||||
```
|
||||
|
||||
The API token must belong to an admin or moderator. Non-staff users receive a
|
||||
permission error.
|
||||
|
||||
## Reporting
|
||||
|
||||
Report the ClawScan verdict first:
|
||||
|
||||
```text
|
||||
pass: 98/124 (79%)
|
||||
malicious: 4/124 (3%)
|
||||
queued: 8
|
||||
running: 1
|
||||
failed: 2
|
||||
```
|
||||
|
||||
When drilling into one artifact, summarize in this order:
|
||||
|
||||
1. ClawScan verdict, status, category, and summary.
|
||||
2. Worker job status, attempts, queue/start/finish/failure times, and last error.
|
||||
3. SkillSpector score, severity, and category as supporting evidence.
|
||||
4. Static analysis and VirusTotal as supporting evidence.
|
||||
|
||||
## Scale Notes
|
||||
|
||||
The dashboard reads security scan digest tables instead of live-scanning every
|
||||
skill, version, package, release, and scan job. This keeps the operator view
|
||||
bounded and index-driven while production data grows.
|
||||
|
||||
This pattern is appropriate while the product needs current rollups, recent
|
||||
hourly trends, failed samples, queue health, and single-artifact drilldown.
|
||||
Reconsider the design if operators need arbitrary historical slicing across many
|
||||
dimensions, very long retention windows in the dashboard, or unbounded exports
|
||||
from the UI. Symptoms to watch for are slow backfills, large hourly rollup
|
||||
growth, repeated Convex `documentsReadLimit` or `bytesReadLimit` errors, or
|
||||
operator workflows that require joining many evidence rows on every request.
|
||||
|
||||
For agent workflows, use the repo-local `security-scan-overview` skill. It
|
||||
keeps the same ClawScan-first reporting order and prefers bounded CLI/API reads
|
||||
over scraping the UI.
|
||||
@@ -120,4 +120,16 @@ bun run mod -- plugins trusted-publisher set <name> --repository <owner/repo> --
|
||||
bun run mod -- plugins trusted-publisher delete <name>
|
||||
```
|
||||
|
||||
Security scan visibility:
|
||||
|
||||
```bash
|
||||
bun run mod -- security-scans overview [--artifact-kind all|skill|plugin] [--window-hours 24] [--json]
|
||||
bun run mod -- security-scans list [--artifact-kind all|skill|plugin] [--verdict <verdict>] [--scan-job-status <status>] [--failure-status failed] [--category <key>] [--json]
|
||||
bun run mod -- security-scans failed [--artifact-kind all|skill|plugin] [--json]
|
||||
bun run mod -- security-scans queued [--artifact-kind all|skill|plugin] [--json]
|
||||
bun run mod -- security-scans running [--artifact-kind all|skill|plugin] [--json]
|
||||
bun run mod -- security-scans inspect --skill <slug> [--json]
|
||||
bun run mod -- security-scans inspect --plugin <package> [--json]
|
||||
```
|
||||
|
||||
All skill and plugin commands accept `--json` where the underlying endpoint supports machine-readable output.
|
||||
|
||||
@@ -44,6 +44,14 @@ import {
|
||||
cmdTransferPackageOwner,
|
||||
cmdUpsertPackageMigration,
|
||||
} from "./commands/packages.js";
|
||||
import {
|
||||
cmdInspectSecurityScanArtifact,
|
||||
cmdListFailedSecurityScans,
|
||||
cmdListQueuedSecurityScans,
|
||||
cmdListRunningSecurityScans,
|
||||
cmdListSecurityScanArtifacts,
|
||||
cmdSecurityScanOverview,
|
||||
} from "./commands/securityScans.js";
|
||||
|
||||
const program = new Command()
|
||||
.name("clawhub-mod")
|
||||
@@ -335,6 +343,13 @@ const skills = program
|
||||
.showHelpAfterError()
|
||||
.showSuggestionAfterError();
|
||||
|
||||
const securityScans = program
|
||||
.command("security-scans")
|
||||
.alias("security-scan")
|
||||
.description("Security scan digest inspection")
|
||||
.showHelpAfterError()
|
||||
.showSuggestionAfterError();
|
||||
|
||||
registerPluginOperations(plugins);
|
||||
registerPluginModerationCommands(plugins);
|
||||
registerPluginGovernanceCommands(plugins);
|
||||
@@ -343,6 +358,7 @@ registerPluginModerationCommands(packages);
|
||||
registerPluginGovernanceCommands(packages);
|
||||
registerOrgCommands(org);
|
||||
registerSkillModerationCommands(skills);
|
||||
registerSecurityScanCommands(securityScans);
|
||||
|
||||
function registerOrgCommands(command: Command) {
|
||||
command
|
||||
@@ -360,6 +376,83 @@ function registerOrgCommands(command: Command) {
|
||||
});
|
||||
}
|
||||
|
||||
function registerSecurityScanCommands(command: Command) {
|
||||
command
|
||||
.command("overview")
|
||||
.description("Show ClawScan-first security scan rollups")
|
||||
.option("--artifact-kind <kind>", "all|skill|plugin", "all")
|
||||
.option("--window-hours <n>", "Recent window size in hours")
|
||||
.option("--failed-limit <n>", "Failed sample size")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdSecurityScanOverview(opts, options);
|
||||
});
|
||||
|
||||
command
|
||||
.command("list")
|
||||
.description("List security scan digest rows")
|
||||
.option("--artifact-kind <kind>", "all|skill|plugin", "all")
|
||||
.option("--verdict <verdict>", "pass|suspicious|malicious|pending|failed|unknown")
|
||||
.option("--scan-job-status <status>", "none|queued|running|succeeded|failed")
|
||||
.option("--failure-status <status>", "none|failed")
|
||||
.option("--category <key>", "ClawScan category key")
|
||||
.option("--cursor <cursor>", "Resume cursor")
|
||||
.option("--limit <n>", "Number of rows to show")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdListSecurityScanArtifacts(opts, options);
|
||||
});
|
||||
|
||||
command
|
||||
.command("failed")
|
||||
.description("List failed security scan rows")
|
||||
.option("--artifact-kind <kind>", "all|skill|plugin", "all")
|
||||
.option("--cursor <cursor>", "Resume cursor")
|
||||
.option("--limit <n>", "Number of rows to show")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdListFailedSecurityScans(opts, options);
|
||||
});
|
||||
|
||||
command
|
||||
.command("queued")
|
||||
.description("List queued security scan rows")
|
||||
.option("--artifact-kind <kind>", "all|skill|plugin", "all")
|
||||
.option("--cursor <cursor>", "Resume cursor")
|
||||
.option("--limit <n>", "Number of rows to show")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdListQueuedSecurityScans(opts, options);
|
||||
});
|
||||
|
||||
command
|
||||
.command("running")
|
||||
.description("List running security scan rows")
|
||||
.option("--artifact-kind <kind>", "all|skill|plugin", "all")
|
||||
.option("--cursor <cursor>", "Resume cursor")
|
||||
.option("--limit <n>", "Number of rows to show")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdListRunningSecurityScans(opts, options);
|
||||
});
|
||||
|
||||
command
|
||||
.command("inspect")
|
||||
.description("Inspect scanner outcomes for one skill or plugin")
|
||||
.option("--skill <slug>", "Skill slug")
|
||||
.option("--plugin <package>", "Plugin package name")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdInspectSecurityScanArtifact(opts, options);
|
||||
});
|
||||
}
|
||||
|
||||
function registerPluginGovernanceCommands(command: Command) {
|
||||
command
|
||||
.command("transfer")
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/* @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 {
|
||||
cmdInspectSecurityScanArtifact,
|
||||
cmdListFailedSecurityScans,
|
||||
cmdListQueuedSecurityScans,
|
||||
cmdListSecurityScanArtifacts,
|
||||
cmdSecurityScanOverview,
|
||||
} = await import("./securityScans");
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function silenceStdout() {
|
||||
return vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
}
|
||||
|
||||
describe("security scan commands", () => {
|
||||
it("prints overview JSON from the security scans endpoint", async () => {
|
||||
const stdout = silenceStdout();
|
||||
const overview = {
|
||||
generatedAt: 1,
|
||||
window: { hours: 24, totalsByKind: {}, rows: [], truncated: false },
|
||||
current: {},
|
||||
failed: { items: [], limit: 10 },
|
||||
};
|
||||
httpMocks.apiRequest.mockResolvedValueOnce(overview);
|
||||
|
||||
const result = await cmdSecurityScanOverview(makeGlobalOpts(), {
|
||||
artifactKind: "skill",
|
||||
windowHours: "24",
|
||||
failedLimit: "3",
|
||||
json: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual(overview);
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
"https://clawhub.ai",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
token: "tkn",
|
||||
url: expect.stringContaining("/api/v1/security-scans/overview?"),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
const request = httpMocks.apiRequest.mock.calls[0]?.[1] as { url: string };
|
||||
expect(request.url).toContain("artifactKind=skill");
|
||||
expect(request.url).toContain("windowHours=24");
|
||||
expect(request.url).toContain("failedLimit=3");
|
||||
expect(stdout).toHaveBeenCalledWith(`${JSON.stringify(overview, null, 2)}\n`);
|
||||
});
|
||||
|
||||
it("lists failed scans across skills and plugins for the all view", async () => {
|
||||
const stdout = silenceStdout();
|
||||
httpMocks.apiRequest
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
artifactKind: "skill",
|
||||
slug: "bad-skill",
|
||||
displayName: "Bad Skill",
|
||||
clawScanVerdict: "failed",
|
||||
scanJobStatus: "failed",
|
||||
failureStatus: "failed",
|
||||
updatedAt: 20,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
done: true,
|
||||
limit: 2,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
artifactKind: "plugin",
|
||||
name: "@bad/plugin",
|
||||
displayName: "Bad Plugin",
|
||||
clawScanVerdict: "malicious",
|
||||
scanJobStatus: "failed",
|
||||
failureStatus: "failed",
|
||||
updatedAt: 10,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
done: true,
|
||||
limit: 2,
|
||||
});
|
||||
|
||||
const result = await cmdListFailedSecurityScans(makeGlobalOpts(), { limit: "2", json: true });
|
||||
|
||||
expect(result.items).toHaveLength(2);
|
||||
const urls = httpMocks.apiRequest.mock.calls.map((call) => (call[1] as { url: string }).url);
|
||||
expect(urls[0]).toContain("artifactKind=skill");
|
||||
expect(urls[0]).toContain("failureStatus=failed");
|
||||
expect(urls[1]).toContain("artifactKind=plugin");
|
||||
expect(urls[1]).toContain("failureStatus=failed");
|
||||
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"artifactKind": "all"'));
|
||||
});
|
||||
|
||||
it("lists queued plugin scans with cursor pagination", async () => {
|
||||
silenceStdout();
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
items: [],
|
||||
nextCursor: "next",
|
||||
done: false,
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
await cmdListQueuedSecurityScans(makeGlobalOpts(), {
|
||||
artifactKind: "plugin",
|
||||
cursor: "c1",
|
||||
limit: 5,
|
||||
json: true,
|
||||
});
|
||||
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledTimes(1);
|
||||
const request = httpMocks.apiRequest.mock.calls[0]?.[1] as { url: string };
|
||||
expect(request.url).toContain("artifactKind=plugin");
|
||||
expect(request.url).toContain("scanJobStatus=queued");
|
||||
expect(request.url).toContain("cursor=c1");
|
||||
expect(request.url).toContain("limit=5");
|
||||
});
|
||||
|
||||
it("inspects a specific skill scan artifact", async () => {
|
||||
silenceStdout();
|
||||
const detail = {
|
||||
found: true,
|
||||
artifactKind: "skill",
|
||||
state: null,
|
||||
artifact: {},
|
||||
scanJob: null,
|
||||
evidence: {},
|
||||
};
|
||||
httpMocks.apiRequest.mockResolvedValueOnce(detail);
|
||||
|
||||
const result = await cmdInspectSecurityScanArtifact(makeGlobalOpts(), {
|
||||
skill: "demo",
|
||||
json: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual(detail);
|
||||
const request = httpMocks.apiRequest.mock.calls[0]?.[1] as { url: string };
|
||||
expect(request.url).toContain("/api/v1/security-scans/artifact?");
|
||||
expect(request.url).toContain("skillSlug=demo");
|
||||
});
|
||||
|
||||
it("rejects ambiguous inspect targets", async () => {
|
||||
await expect(
|
||||
cmdInspectSecurityScanArtifact(makeGlobalOpts(), {
|
||||
skill: "demo",
|
||||
plugin: "@demo/plugin",
|
||||
}),
|
||||
).rejects.toThrow(/exactly one/i);
|
||||
expect(httpMocks.apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects multiple list filters before calling the API", async () => {
|
||||
await expect(
|
||||
cmdListSecurityScanArtifacts(makeGlobalOpts(), {
|
||||
artifactKind: "skill",
|
||||
verdict: "malicious",
|
||||
scanJobStatus: "failed",
|
||||
}),
|
||||
).rejects.toThrow(/at most one/i);
|
||||
expect(httpMocks.apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces auth or permission failures from the endpoint", async () => {
|
||||
httpMocks.apiRequest.mockRejectedValueOnce(new Error("Moderator role required."));
|
||||
|
||||
await expect(
|
||||
cmdSecurityScanOverview(makeGlobalOpts(), { artifactKind: "plugin" }),
|
||||
).rejects.toThrow(/moderator role/i);
|
||||
expect(authTokenMocks.requireAuthToken).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,606 @@
|
||||
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 { fail } from "../../../clawhub/src/cli/ui.js";
|
||||
import { apiRequest, registryUrl } from "../../../clawhub/src/http.js";
|
||||
import {
|
||||
ApiRoutes,
|
||||
ApiV1SecurityScanArtifactListResponseSchema,
|
||||
ApiV1SecurityScanArtifactResponseSchema,
|
||||
ApiV1SecurityScanOverviewResponseSchema,
|
||||
parseArk,
|
||||
} from "../../../clawhub/src/schema/index.js";
|
||||
|
||||
const ARTIFACT_KINDS = ["skill", "plugin"] as const;
|
||||
const ARTIFACT_KIND_ARGS = ["all", ...ARTIFACT_KINDS] as const;
|
||||
const CLAW_SCAN_VERDICTS = ["pass", "suspicious", "malicious", "pending", "failed", "unknown"];
|
||||
const PIPELINE_STATUSES = ["none", "queued", "running", "succeeded", "failed"];
|
||||
const FAILURE_STATUSES = ["none", "failed"];
|
||||
const DEFAULT_LIST_LIMIT = 25;
|
||||
const MAX_LIST_LIMIT = 100;
|
||||
|
||||
type ArtifactKind = (typeof ARTIFACT_KINDS)[number];
|
||||
type ArtifactKindArg = (typeof ARTIFACT_KIND_ARGS)[number];
|
||||
type OverviewResponse = Record<string, unknown>;
|
||||
type ArtifactListResponse = {
|
||||
items: SecurityScanArtifactSummary[];
|
||||
nextCursor: string | null;
|
||||
done: boolean;
|
||||
limit: number;
|
||||
};
|
||||
type ArtifactDetailResponse = Record<string, unknown> & {
|
||||
found?: boolean;
|
||||
artifactKind?: ArtifactKind;
|
||||
state?: SecurityScanArtifactSummary | null;
|
||||
evidence?: Record<string, unknown>;
|
||||
scanJob?: Record<string, unknown> | null;
|
||||
};
|
||||
type SecurityScanArtifactSummary = Record<string, unknown> & {
|
||||
artifactKind?: ArtifactKind;
|
||||
slug?: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
version?: string;
|
||||
artifactKey?: string;
|
||||
clawScanVerdict?: string;
|
||||
clawScanStatus?: string;
|
||||
clawScanPrimaryCategoryKey?: string;
|
||||
clawScanPrimaryCategoryLabel?: string;
|
||||
scanJobStatus?: string;
|
||||
failureStatus?: string;
|
||||
lastError?: string;
|
||||
skillSpectorScore?: number;
|
||||
skillSpectorTopCategory?: string;
|
||||
updatedAt?: number;
|
||||
};
|
||||
|
||||
type SecurityScanOverviewOptions = {
|
||||
artifactKind?: string;
|
||||
windowHours?: string | number;
|
||||
failedLimit?: string | number;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type SecurityScanListOptions = {
|
||||
artifactKind?: string;
|
||||
verdict?: string;
|
||||
clawScanVerdict?: string;
|
||||
scanJobStatus?: string;
|
||||
failureStatus?: string;
|
||||
category?: string;
|
||||
clawScanPrimaryCategoryKey?: string;
|
||||
cursor?: string;
|
||||
limit?: string | number;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type SecurityScanInspectOptions = {
|
||||
skill?: string;
|
||||
plugin?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
export async function cmdSecurityScanOverview(
|
||||
opts: GlobalOpts,
|
||||
options: SecurityScanOverviewOptions = {},
|
||||
) {
|
||||
const artifactKind = normalizeArtifactKindArg(options.artifactKind ?? "all");
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const url = registryUrl(`${ApiRoutes.securityScans}/overview`, registry);
|
||||
if (artifactKind !== "all") url.searchParams.set("artifactKind", artifactKind);
|
||||
setOptionalNumber(url, "windowHours", options.windowHours);
|
||||
setOptionalNumber(url, "failedLimit", options.failedLimit);
|
||||
|
||||
const raw = await apiRequest(
|
||||
registry,
|
||||
{ method: "GET", url: url.toString(), token },
|
||||
ApiV1SecurityScanOverviewResponseSchema,
|
||||
);
|
||||
const result = parseArk(
|
||||
ApiV1SecurityScanOverviewResponseSchema,
|
||||
raw,
|
||||
"Security scan overview response",
|
||||
) as OverviewResponse;
|
||||
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return result;
|
||||
}
|
||||
|
||||
printOverview(result, { registry, artifactKind });
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function cmdListSecurityScanArtifacts(
|
||||
opts: GlobalOpts,
|
||||
options: SecurityScanListOptions = {},
|
||||
) {
|
||||
const artifactKind = normalizeArtifactKindArg(options.artifactKind ?? "all");
|
||||
const listOptions = normalizeListOptions(options);
|
||||
const limit = clampLimit(options.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
|
||||
|
||||
if (artifactKind === "all" && listOptions.cursor) {
|
||||
fail("--cursor requires --artifact-kind skill or --artifact-kind plugin");
|
||||
}
|
||||
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const kinds = artifactKind === "all" ? [...ARTIFACT_KINDS] : [artifactKind];
|
||||
const pages: Record<string, ArtifactListResponse> = {};
|
||||
const items: SecurityScanArtifactSummary[] = [];
|
||||
|
||||
for (const kind of kinds) {
|
||||
const page = await fetchArtifactList(registry, token, kind, { ...listOptions, limit });
|
||||
pages[kind] = page;
|
||||
items.push(...page.items);
|
||||
}
|
||||
|
||||
const sortedItems = items
|
||||
.sort((a, b) => asNumber(b.updatedAt) - asNumber(a.updatedAt))
|
||||
.slice(0, limit);
|
||||
const result =
|
||||
artifactKind === "all"
|
||||
? {
|
||||
artifactKind,
|
||||
items: sortedItems,
|
||||
nextCursor: null,
|
||||
done: Object.values(pages).every((page) => page.done),
|
||||
limit,
|
||||
pages,
|
||||
}
|
||||
: {
|
||||
artifactKind,
|
||||
...pages[artifactKind],
|
||||
};
|
||||
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return result;
|
||||
}
|
||||
|
||||
printArtifactList(sortedItems, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function cmdListFailedSecurityScans(
|
||||
opts: GlobalOpts,
|
||||
options: Omit<SecurityScanListOptions, "failureStatus"> = {},
|
||||
) {
|
||||
return await cmdListSecurityScanArtifacts(opts, { ...options, failureStatus: "failed" });
|
||||
}
|
||||
|
||||
export async function cmdListQueuedSecurityScans(
|
||||
opts: GlobalOpts,
|
||||
options: Omit<SecurityScanListOptions, "scanJobStatus"> = {},
|
||||
) {
|
||||
return await cmdListSecurityScanArtifacts(opts, { ...options, scanJobStatus: "queued" });
|
||||
}
|
||||
|
||||
export async function cmdListRunningSecurityScans(
|
||||
opts: GlobalOpts,
|
||||
options: Omit<SecurityScanListOptions, "scanJobStatus"> = {},
|
||||
) {
|
||||
return await cmdListSecurityScanArtifacts(opts, { ...options, scanJobStatus: "running" });
|
||||
}
|
||||
|
||||
export async function cmdInspectSecurityScanArtifact(
|
||||
opts: GlobalOpts,
|
||||
options: SecurityScanInspectOptions = {},
|
||||
) {
|
||||
const skillSlug = options.skill?.trim();
|
||||
const packageName = options.plugin?.trim();
|
||||
if (Boolean(skillSlug) === Boolean(packageName)) {
|
||||
fail("Pass exactly one of --skill or --plugin");
|
||||
}
|
||||
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const url = registryUrl(`${ApiRoutes.securityScans}/artifact`, registry);
|
||||
if (skillSlug) url.searchParams.set("skillSlug", skillSlug);
|
||||
if (packageName) url.searchParams.set("packageName", packageName);
|
||||
const raw = await apiRequest(
|
||||
registry,
|
||||
{ method: "GET", url: url.toString(), token },
|
||||
ApiV1SecurityScanArtifactResponseSchema,
|
||||
);
|
||||
const result = parseArk(
|
||||
ApiV1SecurityScanArtifactResponseSchema,
|
||||
raw,
|
||||
"Security scan artifact response",
|
||||
) as ArtifactDetailResponse;
|
||||
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return result;
|
||||
}
|
||||
|
||||
printArtifactDetail(result, skillSlug ?? packageName ?? "artifact");
|
||||
return result;
|
||||
}
|
||||
|
||||
async function fetchArtifactList(
|
||||
registry: string,
|
||||
token: string,
|
||||
artifactKind: ArtifactKind,
|
||||
options: NormalizedListOptions,
|
||||
) {
|
||||
const url = registryUrl(`${ApiRoutes.securityScans}/artifacts`, registry);
|
||||
url.searchParams.set("artifactKind", artifactKind);
|
||||
if (options.cursor) url.searchParams.set("cursor", options.cursor);
|
||||
url.searchParams.set("limit", String(options.limit));
|
||||
if (options.clawScanVerdict) url.searchParams.set("clawScanVerdict", options.clawScanVerdict);
|
||||
if (options.scanJobStatus) url.searchParams.set("scanJobStatus", options.scanJobStatus);
|
||||
if (options.failureStatus) url.searchParams.set("failureStatus", options.failureStatus);
|
||||
if (options.clawScanPrimaryCategoryKey) {
|
||||
url.searchParams.set("clawScanPrimaryCategoryKey", options.clawScanPrimaryCategoryKey);
|
||||
}
|
||||
|
||||
const raw = await apiRequest(
|
||||
registry,
|
||||
{ method: "GET", url: url.toString(), token },
|
||||
ApiV1SecurityScanArtifactListResponseSchema,
|
||||
);
|
||||
return parseArk(
|
||||
ApiV1SecurityScanArtifactListResponseSchema,
|
||||
raw,
|
||||
"Security scan artifact list response",
|
||||
) as ArtifactListResponse;
|
||||
}
|
||||
|
||||
type NormalizedListOptions = {
|
||||
cursor?: string;
|
||||
limit: number;
|
||||
clawScanVerdict?: string;
|
||||
scanJobStatus?: string;
|
||||
failureStatus?: string;
|
||||
clawScanPrimaryCategoryKey?: string;
|
||||
};
|
||||
|
||||
function normalizeListOptions(options: SecurityScanListOptions): NormalizedListOptions {
|
||||
const clawScanVerdict = normalizeEnum(
|
||||
options.clawScanVerdict ?? options.verdict,
|
||||
CLAW_SCAN_VERDICTS,
|
||||
"--verdict",
|
||||
);
|
||||
const scanJobStatus = normalizeEnum(
|
||||
options.scanJobStatus,
|
||||
PIPELINE_STATUSES,
|
||||
"--scan-job-status",
|
||||
);
|
||||
const failureStatus = normalizeEnum(options.failureStatus, FAILURE_STATUSES, "--failure-status");
|
||||
const clawScanPrimaryCategoryKey = normalizeOptionalString(
|
||||
options.clawScanPrimaryCategoryKey ?? options.category,
|
||||
);
|
||||
const filterCount = [
|
||||
clawScanVerdict,
|
||||
scanJobStatus,
|
||||
failureStatus,
|
||||
clawScanPrimaryCategoryKey,
|
||||
].filter(Boolean).length;
|
||||
if (filterCount > 1) {
|
||||
fail("Pass at most one of --verdict, --scan-job-status, --failure-status, or --category");
|
||||
}
|
||||
return {
|
||||
cursor: normalizeOptionalString(options.cursor),
|
||||
limit: clampLimit(options.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT),
|
||||
clawScanVerdict,
|
||||
scanJobStatus,
|
||||
failureStatus,
|
||||
clawScanPrimaryCategoryKey,
|
||||
};
|
||||
}
|
||||
|
||||
function printOverview(
|
||||
result: OverviewResponse,
|
||||
context: { registry: string; artifactKind: ArtifactKindArg },
|
||||
) {
|
||||
const window = asRecord(result.window);
|
||||
const current = asRecord(result.current);
|
||||
const kinds = ARTIFACT_KINDS.filter((kind) => current[kind]);
|
||||
const totals = aggregateCurrentTotals(current);
|
||||
const hours = asNumber(window.hours) || 24;
|
||||
|
||||
console.log(`Security scan overview (${formatKindArg(context.artifactKind)}, last ${hours}h)`);
|
||||
console.log(`Registry: ${context.registry}`);
|
||||
console.log(`Current artifacts: ${totals.total}`);
|
||||
console.log("");
|
||||
console.log("Verdicts");
|
||||
for (const verdict of CLAW_SCAN_VERDICTS) {
|
||||
const count = totals.byVerdict[verdict] ?? 0;
|
||||
console.log(` ${verdict}: ${formatCountPercent(count, totals.total)}`);
|
||||
}
|
||||
console.log("");
|
||||
console.log("Pipeline");
|
||||
for (const status of PIPELINE_STATUSES) {
|
||||
const count = totals.byScanJobStatus[status] ?? 0;
|
||||
console.log(` ${status}: ${count}`);
|
||||
}
|
||||
console.log("");
|
||||
console.log("Artifact kinds");
|
||||
for (const kind of kinds) {
|
||||
const totalsForKind = asRecord(asRecord(current[kind]).totals);
|
||||
const total = asNumber(totalsForKind.total);
|
||||
const pass = asNumber(asRecord(totalsForKind.byVerdict).pass);
|
||||
console.log(` ${kind}: ${total} artifacts, pass ${formatCountPercent(pass, total)}`);
|
||||
}
|
||||
|
||||
const windowTotals = aggregateWindowTotals(asRecord(window.totalsByKind));
|
||||
console.log("");
|
||||
console.log("Recent window");
|
||||
console.log(` scan events: ${windowTotals.total}`);
|
||||
console.log(` queued: ${windowTotals.byScanJobStatus.queued ?? 0}`);
|
||||
console.log(` running: ${windowTotals.byScanJobStatus.running ?? 0}`);
|
||||
console.log(` succeeded: ${windowTotals.byScanJobStatus.succeeded ?? 0}`);
|
||||
console.log(` failed: ${windowTotals.byScanJobStatus.failed ?? 0}`);
|
||||
|
||||
const categories = collectCategoryRows(current).slice(0, 8);
|
||||
console.log("");
|
||||
console.log("ClawScan categories");
|
||||
if (categories.length === 0) {
|
||||
console.log(" none");
|
||||
} else {
|
||||
for (const row of categories) {
|
||||
const label = row.categoryLabel ?? row.categoryKey ?? "uncategorized";
|
||||
const count = asNumber(row.count);
|
||||
const total = asNumber(row.totalForKind ?? row.percentageBasis ?? totals.total);
|
||||
console.log(
|
||||
` ${label}: ${formatCountPercent(count, total)} ${row.artifactKind} ${row.clawScanVerdict}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const failedItems = asArray(asRecord(result.failed).items) as SecurityScanArtifactSummary[];
|
||||
console.log("");
|
||||
console.log("Failed scans");
|
||||
if (failedItems.length === 0) {
|
||||
console.log(" none");
|
||||
} else {
|
||||
for (const item of failedItems) console.log(` ${formatArtifactRow(item)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printArtifactList(
|
||||
items: SecurityScanArtifactSummary[],
|
||||
page: { nextCursor?: string | null; done?: boolean; artifactKind?: string },
|
||||
) {
|
||||
if (items.length === 0) {
|
||||
console.log("No security scan artifacts matched.");
|
||||
return;
|
||||
}
|
||||
for (const item of items) {
|
||||
console.log(formatArtifactRow(item));
|
||||
const category = item.clawScanPrimaryCategoryLabel ?? item.clawScanPrimaryCategoryKey;
|
||||
if (category) console.log(` category: ${category}`);
|
||||
if (typeof item.skillSpectorScore === "number") {
|
||||
console.log(
|
||||
` SkillSpector: score=${item.skillSpectorScore}${item.skillSpectorTopCategory ? ` category=${item.skillSpectorTopCategory}` : ""}`,
|
||||
);
|
||||
}
|
||||
if (item.lastError) console.log(` error: ${item.lastError}`);
|
||||
if (item.updatedAt) console.log(` updated: ${formatDate(item.updatedAt)}`);
|
||||
}
|
||||
if (!page.done && page.nextCursor) console.log(`Next cursor: ${page.nextCursor}`);
|
||||
if (page.artifactKind === "all") {
|
||||
console.log("Use --artifact-kind skill or --artifact-kind plugin to paginate with --cursor.");
|
||||
}
|
||||
}
|
||||
|
||||
function printArtifactDetail(result: ArtifactDetailResponse, label: string) {
|
||||
if (result.found === false) {
|
||||
console.log(`No security scan artifact found for ${label}.`);
|
||||
return;
|
||||
}
|
||||
const state = asRecord(result.state);
|
||||
const scanJob = asRecord(result.scanJob);
|
||||
const evidence = asRecord(result.evidence);
|
||||
console.log(`Security scan artifact: ${formatArtifactTitle(state)}`);
|
||||
if (Object.keys(state).length === 0) {
|
||||
console.log("Digest state: none yet");
|
||||
} else {
|
||||
console.log(`ClawScan verdict: ${asDisplayString(state.clawScanVerdict)}`);
|
||||
console.log(`Pipeline status: ${asDisplayString(state.scanJobStatus, "none")}`);
|
||||
console.log(`Failure status: ${asDisplayString(state.failureStatus, "none")}`);
|
||||
const category =
|
||||
optionalDisplayString(state.clawScanPrimaryCategoryLabel) ??
|
||||
optionalDisplayString(state.clawScanPrimaryCategoryKey);
|
||||
if (category) console.log(`Category: ${category}`);
|
||||
const summary = optionalDisplayString(state.clawScanSummary);
|
||||
if (summary) console.log(`Summary: ${summary}`);
|
||||
const lastError = optionalDisplayString(state.lastError);
|
||||
if (lastError) console.log(`Last error: ${lastError}`);
|
||||
}
|
||||
|
||||
const clawScan = asRecord(evidence.clawScan);
|
||||
console.log("");
|
||||
console.log("ClawScan evidence");
|
||||
console.log(` status: ${asDisplayString(clawScan.status)}`);
|
||||
console.log(` verdict: ${asDisplayString(clawScan.verdict)}`);
|
||||
const confidence = optionalDisplayString(clawScan.confidence);
|
||||
if (confidence) console.log(` confidence: ${confidence}`);
|
||||
const clawSummary = optionalDisplayString(clawScan.summary);
|
||||
if (clawSummary) console.log(` summary: ${clawSummary}`);
|
||||
|
||||
const skillSpector = asRecord(evidence.skillSpector);
|
||||
console.log("");
|
||||
console.log("SkillSpector evidence");
|
||||
console.log(` status: ${asDisplayString(skillSpector.status)}`);
|
||||
if (typeof skillSpector.score === "number") console.log(` score: ${skillSpector.score}`);
|
||||
const severity = optionalDisplayString(skillSpector.severity);
|
||||
if (severity) console.log(` severity: ${severity}`);
|
||||
const recommendation = optionalDisplayString(skillSpector.recommendation);
|
||||
if (recommendation) console.log(` recommendation: ${recommendation}`);
|
||||
|
||||
const staticScan = asRecord(evidence.staticScan);
|
||||
const virusTotal = asRecord(evidence.virusTotal);
|
||||
console.log("");
|
||||
console.log("Other evidence");
|
||||
console.log(` static: ${asDisplayString(staticScan.status)}`);
|
||||
console.log(` VirusTotal: ${asDisplayString(virusTotal.verdict ?? virusTotal.status)}`);
|
||||
if (Object.keys(scanJob).length > 0) {
|
||||
console.log("");
|
||||
console.log("Worker");
|
||||
console.log(` job: ${asDisplayString(scanJob._id)}`);
|
||||
console.log(` status: ${asDisplayString(scanJob.status)}`);
|
||||
const workerId = optionalDisplayString(scanJob.workerId);
|
||||
if (workerId) console.log(` worker: ${workerId}`);
|
||||
const error = optionalDisplayString(scanJob.lastError);
|
||||
if (error) console.log(` error: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
function aggregateCurrentTotals(current: Record<string, unknown>) {
|
||||
const totals = emptyAggregate();
|
||||
for (const kind of ARTIFACT_KINDS) {
|
||||
const kindTotals = asRecord(asRecord(current[kind]).totals);
|
||||
addCounts(totals, kindTotals);
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
|
||||
function aggregateWindowTotals(totalsByKind: Record<string, unknown>) {
|
||||
const totals = emptyAggregate();
|
||||
for (const kind of ARTIFACT_KINDS) {
|
||||
addCounts(totals, asRecord(totalsByKind[kind]));
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
|
||||
function emptyAggregate() {
|
||||
return {
|
||||
total: 0,
|
||||
byVerdict: Object.fromEntries(CLAW_SCAN_VERDICTS.map((verdict) => [verdict, 0])),
|
||||
byScanJobStatus: Object.fromEntries(PIPELINE_STATUSES.map((status) => [status, 0])),
|
||||
} as {
|
||||
total: number;
|
||||
byVerdict: Record<string, number>;
|
||||
byScanJobStatus: Record<string, number>;
|
||||
};
|
||||
}
|
||||
|
||||
function addCounts(target: ReturnType<typeof emptyAggregate>, source: Record<string, unknown>) {
|
||||
target.total += asNumber(source.total);
|
||||
const verdicts = asRecord(source.byVerdict);
|
||||
const statuses = asRecord(source.byScanJobStatus);
|
||||
for (const verdict of CLAW_SCAN_VERDICTS)
|
||||
target.byVerdict[verdict] += asNumber(verdicts[verdict]);
|
||||
for (const status of PIPELINE_STATUSES) {
|
||||
target.byScanJobStatus[status] += asNumber(statuses[status]);
|
||||
}
|
||||
}
|
||||
|
||||
function collectCategoryRows(current: Record<string, unknown>) {
|
||||
const rows: Array<Record<string, string | number | undefined>> = [];
|
||||
for (const kind of ARTIFACT_KINDS) {
|
||||
const rollups = asArray(asRecord(current[kind]).rollups);
|
||||
for (const row of rollups) {
|
||||
const record = asRecord(row);
|
||||
if (record.rollupKind !== "clawscanCategory") continue;
|
||||
rows.push({
|
||||
artifactKind: typeof record.artifactKind === "string" ? record.artifactKind : kind,
|
||||
categoryKey: typeof record.categoryKey === "string" ? record.categoryKey : undefined,
|
||||
categoryLabel: typeof record.categoryLabel === "string" ? record.categoryLabel : undefined,
|
||||
clawScanVerdict:
|
||||
typeof record.clawScanVerdict === "string" ? record.clawScanVerdict : undefined,
|
||||
count: asNumber(record.count),
|
||||
totalForKind: asNumber(record.totalForKind),
|
||||
percentageBasis: asNumber(record.percentageBasis),
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows.sort((a, b) => asNumber(b.count) - asNumber(a.count));
|
||||
}
|
||||
|
||||
function normalizeArtifactKindArg(value: string): ArtifactKindArg {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (ARTIFACT_KIND_ARGS.includes(normalized as ArtifactKindArg)) {
|
||||
return normalized as ArtifactKindArg;
|
||||
}
|
||||
return fail("--artifact-kind must be all, skill, or plugin");
|
||||
}
|
||||
|
||||
function normalizeEnum(value: string | undefined, allowed: readonly string[], flag: string) {
|
||||
const normalized = normalizeOptionalString(value)?.toLowerCase();
|
||||
if (!normalized) return undefined;
|
||||
if (allowed.includes(normalized)) return normalized;
|
||||
return fail(`${flag} must be one of ${allowed.join("|")}`);
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: string | undefined) {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function setOptionalNumber(url: URL, name: string, value: string | number | undefined) {
|
||||
if (value === undefined) return;
|
||||
url.searchParams.set(name, String(clampPositiveInt(value, name)));
|
||||
}
|
||||
|
||||
function clampLimit(value: string | number | undefined, fallback: number, max: number) {
|
||||
if (value === undefined) return fallback;
|
||||
return clampPositiveInt(value, "limit", max);
|
||||
}
|
||||
|
||||
function clampPositiveInt(value: string | number, label: string, max = 10_000) {
|
||||
const parsed = typeof value === "number" ? value : Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 1) fail(`${label} must be a positive integer`);
|
||||
return Math.min(max, Math.floor(parsed));
|
||||
}
|
||||
|
||||
function formatKindArg(kind: ArtifactKindArg) {
|
||||
if (kind === "all") return "skills and plugins";
|
||||
return kind === "skill" ? "skills" : "plugins";
|
||||
}
|
||||
|
||||
function formatCountPercent(count: number, total: number) {
|
||||
if (total <= 0) return `${count}/0 (0%)`;
|
||||
return `${count}/${total} (${Math.round((count / total) * 100)}%)`;
|
||||
}
|
||||
|
||||
function formatArtifactRow(item: SecurityScanArtifactSummary) {
|
||||
const title = formatArtifactTitle(item);
|
||||
const version = item.version ? `@${item.version}` : "";
|
||||
const verdict = item.clawScanVerdict ?? "unknown";
|
||||
const job = item.scanJobStatus ?? "none";
|
||||
const failure = item.failureStatus === "failed" ? " failure=failed" : "";
|
||||
return `${item.artifactKind ?? "artifact"} ${title}${version} verdict=${verdict} job=${job}${failure}`;
|
||||
}
|
||||
|
||||
function formatArtifactTitle(item: Record<string, unknown>) {
|
||||
const displayName = typeof item.displayName === "string" ? item.displayName : undefined;
|
||||
const slug = typeof item.slug === "string" ? item.slug : undefined;
|
||||
const name = typeof item.name === "string" ? item.name : undefined;
|
||||
const artifactKey = typeof item.artifactKey === "string" ? item.artifactKey : undefined;
|
||||
return displayName ?? slug ?? name ?? artifactKey ?? "unknown";
|
||||
}
|
||||
|
||||
function formatDate(value: number) {
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function optionalDisplayString(value: unknown) {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function asDisplayString(value: unknown, fallback = "unknown") {
|
||||
return optionalDisplayString(value) ?? fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export const ApiRoutes = {
|
||||
stars: "/api/v1/stars",
|
||||
transfers: "/api/v1/transfers",
|
||||
publishers: "/api/v1/publishers",
|
||||
securityScans: "/api/v1/security-scans",
|
||||
souls: "/api/v1/souls",
|
||||
users: "/api/v1/users",
|
||||
whoami: "/api/v1/whoami",
|
||||
|
||||
@@ -563,6 +563,39 @@ export const ApiV1RemediateAutobansResponseSchema = type({
|
||||
"done?": "boolean",
|
||||
});
|
||||
|
||||
export const ApiV1SecurityScanOverviewResponseSchema = type({
|
||||
generatedAt: "number",
|
||||
window: "unknown",
|
||||
current: "unknown",
|
||||
failed: {
|
||||
items: "unknown[]",
|
||||
limit: "number",
|
||||
},
|
||||
});
|
||||
export type ApiV1SecurityScanOverviewResponse =
|
||||
(typeof ApiV1SecurityScanOverviewResponseSchema)[inferred];
|
||||
|
||||
export const ApiV1SecurityScanArtifactListResponseSchema = type({
|
||||
items: "unknown[]",
|
||||
nextCursor: "string|null",
|
||||
done: "boolean",
|
||||
limit: "number",
|
||||
});
|
||||
export type ApiV1SecurityScanArtifactListResponse =
|
||||
(typeof ApiV1SecurityScanArtifactListResponseSchema)[inferred];
|
||||
|
||||
export const ApiV1SecurityScanArtifactResponseSchema = type({
|
||||
found: "boolean",
|
||||
artifactKind: '"skill"|"plugin"',
|
||||
"state?": "unknown|null",
|
||||
"artifact?": "unknown",
|
||||
"scanJob?": "unknown|null",
|
||||
"evidence?": "unknown",
|
||||
"reason?": "string",
|
||||
});
|
||||
export type ApiV1SecurityScanArtifactResponse =
|
||||
(typeof ApiV1SecurityScanArtifactResponseSchema)[inferred];
|
||||
|
||||
export const ApiV1SetRoleResponseSchema = type({
|
||||
ok: "true",
|
||||
role: '"admin"|"moderator"|"user"',
|
||||
|
||||
Vendored
+1
@@ -23,6 +23,7 @@ export declare const ApiRoutes: {
|
||||
readonly stars: "/api/v1/stars";
|
||||
readonly transfers: "/api/v1/transfers";
|
||||
readonly publishers: "/api/v1/publishers";
|
||||
readonly securityScans: "/api/v1/security-scans";
|
||||
readonly souls: "/api/v1/souls";
|
||||
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",
|
||||
publishers: "/api/v1/publishers",
|
||||
securityScans: "/api/v1/security-scans",
|
||||
souls: "/api/v1/souls",
|
||||
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,UAAU,EAAE,oBAAoB;IAChC,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;IACxB,YAAY,EAAE,uBAAuB;CAC7B,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,UAAU,EAAE,oBAAoB;IAChC,aAAa,EAAE,wBAAwB;IACvC,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;IACxB,YAAY,EAAE,uBAAuB;CAC7B,CAAC"}
|
||||
Vendored
+27
@@ -516,6 +516,33 @@ export declare const ApiV1RemediateAutobansResponseSchema: import("arktype/inter
|
||||
nextCursor?: string | null | undefined;
|
||||
done?: boolean | undefined;
|
||||
}, {}>;
|
||||
export declare const ApiV1SecurityScanOverviewResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
generatedAt: number;
|
||||
window: unknown;
|
||||
current: unknown;
|
||||
failed: {
|
||||
items: unknown[];
|
||||
limit: number;
|
||||
};
|
||||
}, {}>;
|
||||
export type ApiV1SecurityScanOverviewResponse = (typeof ApiV1SecurityScanOverviewResponseSchema)[inferred];
|
||||
export declare const ApiV1SecurityScanArtifactListResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
items: unknown[];
|
||||
nextCursor: string | null;
|
||||
done: boolean;
|
||||
limit: number;
|
||||
}, {}>;
|
||||
export type ApiV1SecurityScanArtifactListResponse = (typeof ApiV1SecurityScanArtifactListResponseSchema)[inferred];
|
||||
export declare const ApiV1SecurityScanArtifactResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
found: boolean;
|
||||
artifactKind: "skill" | "plugin";
|
||||
state?: unknown;
|
||||
artifact?: unknown;
|
||||
scanJob?: unknown;
|
||||
evidence?: unknown;
|
||||
reason?: string | undefined;
|
||||
}, {}>;
|
||||
export type ApiV1SecurityScanArtifactResponse = (typeof ApiV1SecurityScanArtifactResponseSchema)[inferred];
|
||||
export declare const ApiV1StarResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
ok: true;
|
||||
starred: boolean;
|
||||
|
||||
Vendored
+24
@@ -470,6 +470,30 @@ export const ApiV1RemediateAutobansResponseSchema = type({
|
||||
"nextCursor?": "string|null",
|
||||
"done?": "boolean",
|
||||
});
|
||||
export const ApiV1SecurityScanOverviewResponseSchema = type({
|
||||
generatedAt: "number",
|
||||
window: "unknown",
|
||||
current: "unknown",
|
||||
failed: {
|
||||
items: "unknown[]",
|
||||
limit: "number",
|
||||
},
|
||||
});
|
||||
export const ApiV1SecurityScanArtifactListResponseSchema = type({
|
||||
items: "unknown[]",
|
||||
nextCursor: "string|null",
|
||||
done: "boolean",
|
||||
limit: "number",
|
||||
});
|
||||
export const ApiV1SecurityScanArtifactResponseSchema = type({
|
||||
found: "boolean",
|
||||
artifactKind: '"skill"|"plugin"',
|
||||
"state?": "unknown|null",
|
||||
"artifact?": "unknown",
|
||||
"scanJob?": "unknown|null",
|
||||
"evidence?": "unknown",
|
||||
"reason?": "string",
|
||||
});
|
||||
export const ApiV1StarResponseSchema = type({
|
||||
ok: "true",
|
||||
starred: "boolean",
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -24,6 +24,7 @@ export const ApiRoutes = {
|
||||
stars: "/api/v1/stars",
|
||||
transfers: "/api/v1/transfers",
|
||||
publishers: "/api/v1/publishers",
|
||||
securityScans: "/api/v1/security-scans",
|
||||
souls: "/api/v1/souls",
|
||||
users: "/api/v1/users",
|
||||
whoami: "/api/v1/whoami",
|
||||
|
||||
@@ -550,6 +550,39 @@ export const ApiV1RemediateAutobansResponseSchema = type({
|
||||
"done?": "boolean",
|
||||
});
|
||||
|
||||
export const ApiV1SecurityScanOverviewResponseSchema = type({
|
||||
generatedAt: "number",
|
||||
window: "unknown",
|
||||
current: "unknown",
|
||||
failed: {
|
||||
items: "unknown[]",
|
||||
limit: "number",
|
||||
},
|
||||
});
|
||||
export type ApiV1SecurityScanOverviewResponse =
|
||||
(typeof ApiV1SecurityScanOverviewResponseSchema)[inferred];
|
||||
|
||||
export const ApiV1SecurityScanArtifactListResponseSchema = type({
|
||||
items: "unknown[]",
|
||||
nextCursor: "string|null",
|
||||
done: "boolean",
|
||||
limit: "number",
|
||||
});
|
||||
export type ApiV1SecurityScanArtifactListResponse =
|
||||
(typeof ApiV1SecurityScanArtifactListResponseSchema)[inferred];
|
||||
|
||||
export const ApiV1SecurityScanArtifactResponseSchema = type({
|
||||
found: "boolean",
|
||||
artifactKind: '"skill"|"plugin"',
|
||||
"state?": "unknown|null",
|
||||
"artifact?": "unknown",
|
||||
"scanJob?": "unknown|null",
|
||||
"evidence?": "unknown",
|
||||
"reason?": "string",
|
||||
});
|
||||
export type ApiV1SecurityScanArtifactResponse =
|
||||
(typeof ApiV1SecurityScanArtifactResponseSchema)[inferred];
|
||||
|
||||
export const ApiV1StarResponseSchema = type({
|
||||
ok: "true",
|
||||
starred: "boolean",
|
||||
|
||||
@@ -154,6 +154,67 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic
|
||||
- Packages cache VirusTotal undetected-only engine results as clean VT telemetry.
|
||||
ClawHub does not request or consume VirusTotal AI/code-insight results; VT is
|
||||
engine/vendor telemetry only.
|
||||
- Staff production visibility reads ClawScan status from digest tables instead
|
||||
of aggregating `skills`, `skillVersions`, `packages`, `packageReleases`, and
|
||||
`securityScanJobs` live. `securityScanArtifactStates` has one current row per
|
||||
latest skill/plugin artifact, keyed by artifact and target version/release.
|
||||
ClawScan/Codex `llmAnalysis` is the verdict source of truth; SkillSpector,
|
||||
static analysis, and VirusTotal fields are evidence for drilldown, not the
|
||||
primary verdict.
|
||||
- Staff visibility state names are operator summaries, not independent
|
||||
moderation states:
|
||||
- `pass`: ClawScan/Codex resolved the latest artifact without visible review
|
||||
concerns.
|
||||
- `suspicious`: ClawScan/Codex found review-worthy or high-impact concerns,
|
||||
but the result is not a malicious block.
|
||||
- `malicious`: ClawScan/Codex determined the artifact should not be installed
|
||||
and normal malicious-artifact moderation applies.
|
||||
- `pending`: the latest artifact is queued or running, or ClawScan has not
|
||||
produced a final result yet.
|
||||
- `failed`: the scan pipeline exhausted the current job unsuccessfully.
|
||||
- `unknown`: no usable ClawScan result or current scan job exists for the
|
||||
latest artifact.
|
||||
- Pipeline status (`queued`, `running`, `succeeded`, `failed`, `none`) explains
|
||||
worker state; failure status only distinguishes final failed rows from normal
|
||||
non-failed rows.
|
||||
- `securityScanCurrentRollups` stores current counts by artifact kind,
|
||||
ClawScan verdict, pipeline status, failure status, and optional ClawScan risk
|
||||
category. `securityScanHourlyRollups` stores bounded hourly scan-event counts
|
||||
for recent time-window views. Hourly writes must go through idempotent
|
||||
`securityScanHourlyRollupEvents` rows keyed by the source scan event/job so a
|
||||
replayed lifecycle write does not double-count. These rollups are
|
||||
intentionally narrow counters, not an audit ledger; detailed truth remains on
|
||||
the version/release, scan job, moderation timeline, and `auditLogs` rows.
|
||||
- Scan lifecycle mutations keep digest rows warm for current latest artifacts:
|
||||
enqueue writes queued state, claim writes running state, success writes the
|
||||
final ClawScan verdict after artifact evidence is persisted, and failure
|
||||
writes sanitized reason plus worker/attempt metadata. Retry transitions move
|
||||
the current row back to queued instead of incrementing a separate failed
|
||||
current count; final failures set `failureStatus: failed`. If the action
|
||||
writes a synthetic ClawScan error after retries are exhausted, it refreshes
|
||||
the same digest row without replaying the hourly failure event.
|
||||
- Staff overview APIs are read-only and role-gated to admins/moderators. They
|
||||
must read current and hourly digest tables through indexes, return percentage
|
||||
bases with each rollup, and paginate drilldown artifact rows instead of
|
||||
scanning source artifact tables. Skill/plugin lookup resolves the public
|
||||
artifact identifier, returns the current digest state, compact evidence
|
||||
summaries from the latest version/release, and a sanitized scan-job summary
|
||||
that excludes worker lease tokens.
|
||||
- Digest repair is cursor-based and replayable. Backfill page mutations rebuild
|
||||
active skill/plugin artifact states from indexed active listings and latest
|
||||
version/release rows, then adjust current rollups from previous state to next
|
||||
state in the same mutation. If artifact-state rows drift, operators should
|
||||
rerun the relevant backfill from a null cursor and continue with returned
|
||||
cursors until `isDone` is true, then run stale-state pruning pages to remove
|
||||
digest rows for artifacts that were deleted or superseded while the digest was
|
||||
stale. If current rollup counts drift independently, operators should clear
|
||||
current rollup pages for the artifact kind and rebuild them from
|
||||
`securityScanArtifactStates`.
|
||||
- Scale warning: these digests are appropriate while the dashboard needs compact
|
||||
operational visibility. If product requirements shift toward arbitrary
|
||||
historical slicing across many dimensions, the risk is row explosion in hourly
|
||||
rollups and expensive repair runs; prefer adding one specific indexed
|
||||
dimension at a time over storing every scanner finding as a rollup row.
|
||||
- Skill moderation state stores a structured ClawScan moderation snapshot:
|
||||
- `moderationVerdict`: `clean | suspicious | malicious`
|
||||
- `moderationReasonCodes[]`: canonical machine-readable reasons
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SecurityScanOverview } from "./SecurityScanOverview";
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
search,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
to?: string;
|
||||
search?: Record<string, string | undefined>;
|
||||
}) => {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(search ?? {})) {
|
||||
if (value) params.set(key, value);
|
||||
}
|
||||
const query = params.toString();
|
||||
return <a href={`${to ?? "/"}${query ? `?${query}` : ""}`}>{children}</a>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../ui/select", () => ({
|
||||
Select: ({
|
||||
children,
|
||||
value,
|
||||
onValueChange,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
}) => (
|
||||
<select value={value} onChange={(event) => onValueChange(event.target.value)}>
|
||||
{children}
|
||||
</select>
|
||||
),
|
||||
SelectTrigger: ({ children }: { children: React.ReactNode }) => children,
|
||||
SelectValue: () => null,
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => children,
|
||||
SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => (
|
||||
<option value={value}>{children}</option>
|
||||
),
|
||||
}));
|
||||
|
||||
const NOW = Date.UTC(2026, 4, 26, 12, 0, 0);
|
||||
|
||||
function makeCounts(overrides?: {
|
||||
total?: number;
|
||||
pass?: number;
|
||||
suspicious?: number;
|
||||
malicious?: number;
|
||||
pending?: number;
|
||||
failed?: number;
|
||||
queued?: number;
|
||||
running?: number;
|
||||
succeeded?: number;
|
||||
}) {
|
||||
return {
|
||||
total: overrides?.total ?? 0,
|
||||
byVerdict: {
|
||||
pass: overrides?.pass ?? 0,
|
||||
suspicious: overrides?.suspicious ?? 0,
|
||||
malicious: overrides?.malicious ?? 0,
|
||||
pending: overrides?.pending ?? 0,
|
||||
failed: overrides?.failed ?? 0,
|
||||
unknown: 0,
|
||||
},
|
||||
byScanJobStatus: {
|
||||
none: 0,
|
||||
queued: overrides?.queued ?? 0,
|
||||
running: overrides?.running ?? 0,
|
||||
succeeded: overrides?.succeeded ?? 0,
|
||||
failed: overrides?.failed ?? 0,
|
||||
},
|
||||
byFailureStatus: {
|
||||
none: (overrides?.total ?? 0) - (overrides?.failed ?? 0),
|
||||
failed: overrides?.failed ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeOverview() {
|
||||
return {
|
||||
generatedAt: NOW,
|
||||
current: {
|
||||
skill: {
|
||||
totals: makeCounts({
|
||||
total: 10,
|
||||
pass: 6,
|
||||
suspicious: 1,
|
||||
malicious: 1,
|
||||
pending: 1,
|
||||
failed: 1,
|
||||
queued: 1,
|
||||
running: 2,
|
||||
succeeded: 6,
|
||||
}),
|
||||
rollups: [
|
||||
{
|
||||
artifactKind: "skill",
|
||||
rollupKind: "clawscanCategory",
|
||||
categoryKey: "permission_boundary",
|
||||
categoryLabel: "Permission boundary",
|
||||
clawScanVerdict: "malicious",
|
||||
scanJobStatus: "succeeded",
|
||||
failureStatus: "none",
|
||||
count: 1,
|
||||
totalForKind: 10,
|
||||
percentageBasis: 10,
|
||||
updatedAt: NOW,
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
},
|
||||
plugin: {
|
||||
totals: makeCounts({ total: 5, pass: 4, failed: 1, succeeded: 4 }),
|
||||
rollups: [],
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
window: {
|
||||
hours: 24,
|
||||
startMs: NOW - 24 * 60 * 60 * 1000,
|
||||
endMs: NOW,
|
||||
totalsByKind: {
|
||||
skill: makeCounts({ total: 4, pass: 2, pending: 1, failed: 1, queued: 1, succeeded: 2 }),
|
||||
plugin: makeCounts({ total: 1, failed: 1 }),
|
||||
},
|
||||
rows: [
|
||||
{
|
||||
bucketStartMs: NOW - 60 * 60 * 1000,
|
||||
artifactKind: "skill",
|
||||
clawScanVerdict: "pass",
|
||||
scanJobStatus: "succeeded",
|
||||
failureStatus: "none",
|
||||
count: 2,
|
||||
updatedAt: NOW,
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
},
|
||||
failed: {
|
||||
limit: 8,
|
||||
items: [
|
||||
{
|
||||
artifactKind: "plugin",
|
||||
artifactKey: "plugin:packages:demo",
|
||||
targetKey: "packageRelease:packageReleases:demo",
|
||||
packageId: "packages:demo",
|
||||
packageReleaseId: "packageReleases:demo",
|
||||
ownerUserId: "users:owner",
|
||||
name: "@openclaw/demo",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.0.0",
|
||||
clawScanVerdict: "failed",
|
||||
scanJobStatus: "failed",
|
||||
failureStatus: "failed",
|
||||
lastError: "Worker timeout",
|
||||
createdAt: NOW - 10_000,
|
||||
updatedAt: NOW,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeDetail() {
|
||||
return {
|
||||
found: true,
|
||||
artifactKind: "skill",
|
||||
state: {
|
||||
artifactKind: "skill",
|
||||
artifactKey: "skill:skills:demo",
|
||||
targetKey: "skillVersion:skillVersions:demo",
|
||||
skillId: "skills:demo",
|
||||
skillVersionId: "skillVersions:demo",
|
||||
ownerUserId: "users:owner",
|
||||
slug: "demo-skill",
|
||||
displayName: "Demo Skill",
|
||||
version: "1.2.3",
|
||||
clawScanVerdict: "malicious",
|
||||
clawScanStatus: "malicious",
|
||||
clawScanPrimaryCategoryLabel: "Permission boundary",
|
||||
scanJobStatus: "succeeded",
|
||||
failureStatus: "none",
|
||||
skillSpectorScore: 85,
|
||||
skillSpectorIssueCount: 12,
|
||||
staticStatus: "suspicious",
|
||||
vtVerdict: "clean",
|
||||
createdAt: NOW - 10_000,
|
||||
updatedAt: NOW,
|
||||
},
|
||||
scanJob: {
|
||||
_id: "securityScanJobs:demo",
|
||||
status: "succeeded",
|
||||
source: "manual",
|
||||
attempts: 2,
|
||||
workerId: "worker-a",
|
||||
updatedAt: NOW,
|
||||
},
|
||||
evidence: {
|
||||
clawScan: {
|
||||
status: "malicious",
|
||||
verdict: "malicious",
|
||||
confidence: "high",
|
||||
summary: "Exfiltrates secrets",
|
||||
checkedAt: NOW,
|
||||
},
|
||||
skillSpector: {
|
||||
status: "suspicious",
|
||||
score: 85,
|
||||
severity: "high",
|
||||
issueCount: 12,
|
||||
issues: [
|
||||
{
|
||||
issueId: "network-egress",
|
||||
severity: "high",
|
||||
finding: "Broad network egress",
|
||||
},
|
||||
],
|
||||
},
|
||||
staticScan: {
|
||||
status: "suspicious",
|
||||
reasonCodes: ["network-egress"],
|
||||
summary: "Network use",
|
||||
checkedAt: NOW,
|
||||
},
|
||||
virusTotal: {
|
||||
verdict: "clean",
|
||||
malicious: 0,
|
||||
suspicious: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("SecurityScanOverview", () => {
|
||||
beforeEach(() => {
|
||||
useQueryMock.mockReset();
|
||||
});
|
||||
|
||||
it("renders a loading state while the overview query resolves", () => {
|
||||
useQueryMock.mockReturnValue(undefined);
|
||||
|
||||
render(<SecurityScanOverview />);
|
||||
|
||||
expect(screen.getByText("Loading security scan overview...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders verdict, category, time-window, failed, queued, and running scan summaries", () => {
|
||||
useQueryMock.mockImplementation((_query: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
return makeOverview();
|
||||
});
|
||||
|
||||
render(<SecurityScanOverview />);
|
||||
|
||||
expect(screen.getByText("Security scans")).toBeTruthy();
|
||||
expect(screen.getByText("15 artifacts")).toBeTruthy();
|
||||
expect(screen.getByText("10/15 (67%)")).toBeTruthy();
|
||||
expect(screen.getByText("Permission boundary")).toBeTruthy();
|
||||
expect(screen.getByText("5 scan events")).toBeTruthy();
|
||||
expect(screen.getByText("Demo Plugin")).toBeTruthy();
|
||||
expect(screen.getByText("Worker timeout")).toBeTruthy();
|
||||
expect(screen.getByText("Queued")).toBeTruthy();
|
||||
expect(screen.getByText("Running")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("submits artifact drilldown and renders ClawScan and evidence scanner details", async () => {
|
||||
useQueryMock.mockImplementation((_query: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
if (typeof args === "object" && args && "skillSlug" in args) return makeDetail();
|
||||
return makeOverview();
|
||||
});
|
||||
|
||||
render(<SecurityScanOverview />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("agentic-risk-demo"), {
|
||||
target: { value: "demo-skill" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Inspect" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Demo Skill")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText("Exfiltrates secrets")).toBeTruthy();
|
||||
expect(screen.getByText("Broad network egress")).toBeTruthy();
|
||||
expect(screen.getByText("worker-a")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses the artifact summary when a drilldown has evidence but no digest state", async () => {
|
||||
useQueryMock.mockImplementation((_query: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
if (typeof args === "object" && args && "skillSlug" in args) {
|
||||
return {
|
||||
...makeDetail(),
|
||||
state: null,
|
||||
artifact: {
|
||||
skill: {
|
||||
_id: "skills:demo",
|
||||
slug: "demo-skill",
|
||||
displayName: "Local Agentic Risk Demo",
|
||||
},
|
||||
version: {
|
||||
_id: "skillVersions:demo",
|
||||
version: "1.2.3",
|
||||
createdAt: NOW,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return makeOverview();
|
||||
});
|
||||
|
||||
render(<SecurityScanOverview />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("agentic-risk-demo"), {
|
||||
target: { value: "demo-skill" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Inspect" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Local Agentic Risk Demo")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText(/No digest state/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,803 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "convex/react";
|
||||
import { type FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import type { Id } from "../../../convex/_generated/dataModel";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Button } from "../ui/button";
|
||||
import { Card } from "../ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
|
||||
|
||||
type ArtifactKind = "skill" | "plugin";
|
||||
type ArtifactKindFilter = "all" | ArtifactKind;
|
||||
type ClawScanVerdict = "pass" | "suspicious" | "malicious" | "pending" | "failed" | "unknown";
|
||||
type PipelineStatus = "none" | "queued" | "running" | "succeeded" | "failed";
|
||||
type FailureStatus = "none" | "failed";
|
||||
|
||||
type CountBucket = {
|
||||
total: number;
|
||||
byVerdict: Record<ClawScanVerdict, number>;
|
||||
byScanJobStatus: Record<PipelineStatus, number>;
|
||||
byFailureStatus: Record<FailureStatus, number>;
|
||||
};
|
||||
|
||||
type CurrentRollup = {
|
||||
artifactKind: ArtifactKind;
|
||||
rollupKind: "all" | "clawscanRiskBucket" | "clawscanCategory";
|
||||
categoryKey: string;
|
||||
categoryLabel?: string;
|
||||
clawScanVerdict: ClawScanVerdict;
|
||||
scanJobStatus: PipelineStatus;
|
||||
failureStatus: FailureStatus;
|
||||
count: number;
|
||||
totalForKind: number;
|
||||
percentageBasis: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
type HourlyRollup = {
|
||||
bucketStartMs: number;
|
||||
artifactKind: ArtifactKind;
|
||||
clawScanVerdict: ClawScanVerdict;
|
||||
scanJobStatus: PipelineStatus;
|
||||
failureStatus: FailureStatus;
|
||||
count: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
type ArtifactStateSummary = {
|
||||
artifactKind: ArtifactKind;
|
||||
artifactKey: string;
|
||||
targetKey: string;
|
||||
skillId?: Id<"skills">;
|
||||
skillVersionId?: Id<"skillVersions">;
|
||||
packageId?: Id<"packages">;
|
||||
packageReleaseId?: Id<"packageReleases">;
|
||||
ownerUserId: Id<"users">;
|
||||
ownerPublisherId?: Id<"publishers">;
|
||||
slug?: string;
|
||||
name?: string;
|
||||
displayName: string;
|
||||
version?: string;
|
||||
clawScanVerdict: ClawScanVerdict;
|
||||
clawScanStatus?: string;
|
||||
clawScanCheckedAt?: number;
|
||||
clawScanSummary?: string;
|
||||
clawScanModel?: string;
|
||||
clawScanPrimaryRiskBucket?: string;
|
||||
clawScanPrimaryCategoryKey?: string;
|
||||
clawScanPrimaryCategoryLabel?: string;
|
||||
clawScanVisibleFindingCount?: number;
|
||||
clawScanHighestSeverity?: string;
|
||||
scanJobStatus: PipelineStatus;
|
||||
failureStatus: FailureStatus;
|
||||
lastScanWorkerId?: string;
|
||||
lastScanAttempts?: number;
|
||||
lastScanUpdatedAt?: number;
|
||||
lastError?: string;
|
||||
skillSpectorStatus?: string;
|
||||
skillSpectorScore?: number;
|
||||
skillSpectorSeverity?: string;
|
||||
skillSpectorRecommendation?: string;
|
||||
skillSpectorIssueCount?: number;
|
||||
skillSpectorTopCategory?: string;
|
||||
staticStatus?: string;
|
||||
staticReasonCount?: number;
|
||||
vtStatus?: string;
|
||||
vtVerdict?: string;
|
||||
vtMalicious?: number;
|
||||
vtSuspicious?: number;
|
||||
evidenceUpdatedAt?: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
type SecurityScanOverviewResult = {
|
||||
generatedAt: number;
|
||||
window: {
|
||||
hours: number;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
totalsByKind: Partial<Record<ArtifactKind, CountBucket>>;
|
||||
rows: HourlyRollup[];
|
||||
truncated: boolean;
|
||||
};
|
||||
current: Partial<
|
||||
Record<
|
||||
ArtifactKind,
|
||||
{
|
||||
totals: CountBucket;
|
||||
rollups: CurrentRollup[];
|
||||
truncated: boolean;
|
||||
}
|
||||
>
|
||||
>;
|
||||
failed: {
|
||||
items: ArtifactStateSummary[];
|
||||
limit: number;
|
||||
};
|
||||
};
|
||||
|
||||
type SecurityScanArtifactDetail = {
|
||||
found: boolean;
|
||||
artifactKind: ArtifactKind;
|
||||
reason?: "missing";
|
||||
state: ArtifactStateSummary | null;
|
||||
artifact?: {
|
||||
skill?: {
|
||||
_id: Id<"skills">;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
latestVersionId?: Id<"skillVersions">;
|
||||
};
|
||||
version?: {
|
||||
_id: Id<"skillVersions">;
|
||||
version: string;
|
||||
createdAt: number;
|
||||
} | null;
|
||||
package?: {
|
||||
_id: Id<"packages">;
|
||||
name: string;
|
||||
displayName: string;
|
||||
family: string;
|
||||
latestReleaseId?: Id<"packageReleases">;
|
||||
};
|
||||
release?: {
|
||||
_id: Id<"packageReleases">;
|
||||
version: string;
|
||||
createdAt: number;
|
||||
} | null;
|
||||
};
|
||||
scanJob?: {
|
||||
_id: Id<"securityScanJobs">;
|
||||
status: string;
|
||||
source: string;
|
||||
attempts: number;
|
||||
workerId?: string;
|
||||
lastError?: string;
|
||||
updatedAt: number;
|
||||
} | null;
|
||||
evidence?: {
|
||||
clawScan: {
|
||||
status?: string;
|
||||
verdict?: string;
|
||||
confidence?: string;
|
||||
summary?: string;
|
||||
guidance?: string;
|
||||
findings?: string;
|
||||
model?: string;
|
||||
checkedAt?: number;
|
||||
};
|
||||
skillSpector: {
|
||||
status?: string;
|
||||
score?: number;
|
||||
severity?: string;
|
||||
recommendation?: string;
|
||||
issueCount?: number;
|
||||
checkedAt?: number;
|
||||
issues: Array<{
|
||||
issueId?: string;
|
||||
severity?: string;
|
||||
explanation?: string;
|
||||
finding?: string;
|
||||
}>;
|
||||
};
|
||||
staticScan?: {
|
||||
status: string;
|
||||
reasonCodes: string[];
|
||||
summary: string;
|
||||
checkedAt: number;
|
||||
};
|
||||
virusTotal?: {
|
||||
status?: string;
|
||||
verdict?: string;
|
||||
malicious?: number;
|
||||
suspicious?: number;
|
||||
checkedAt?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const VERDICTS: ClawScanVerdict[] = [
|
||||
"pass",
|
||||
"suspicious",
|
||||
"malicious",
|
||||
"pending",
|
||||
"failed",
|
||||
"unknown",
|
||||
];
|
||||
|
||||
const PIPELINE_STATUSES: PipelineStatus[] = ["queued", "running", "succeeded", "failed"];
|
||||
|
||||
type SecurityScanOverviewProps = {
|
||||
selectedSkillSlug?: string;
|
||||
selectedPluginName?: string;
|
||||
};
|
||||
|
||||
export function SecurityScanOverview({
|
||||
selectedSkillSlug,
|
||||
selectedPluginName,
|
||||
}: SecurityScanOverviewProps) {
|
||||
const [artifactKind, setArtifactKind] = useState<ArtifactKindFilter>("all");
|
||||
const [windowHours, setWindowHours] = useState("24");
|
||||
const [lookupKind, setLookupKind] = useState<ArtifactKind>("skill");
|
||||
const [lookupValue, setLookupValue] = useState("");
|
||||
const [submittedLookup, setSubmittedLookup] = useState<{
|
||||
kind: ArtifactKind;
|
||||
value: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSkillSlug) {
|
||||
setLookupKind("skill");
|
||||
setLookupValue(selectedSkillSlug);
|
||||
setSubmittedLookup({ kind: "skill", value: selectedSkillSlug });
|
||||
return;
|
||||
}
|
||||
if (selectedPluginName) {
|
||||
setLookupKind("plugin");
|
||||
setLookupValue(selectedPluginName);
|
||||
setSubmittedLookup({ kind: "plugin", value: selectedPluginName });
|
||||
}
|
||||
}, [selectedPluginName, selectedSkillSlug]);
|
||||
|
||||
const overviewArgs = {
|
||||
artifactKind: artifactKind === "all" ? undefined : artifactKind,
|
||||
windowHours: Number(windowHours),
|
||||
failedLimit: 8,
|
||||
};
|
||||
const overview = useQuery(api.securityScanDigests.getStaffSecurityScanOverview, overviewArgs) as
|
||||
| SecurityScanOverviewResult
|
||||
| undefined;
|
||||
|
||||
const detailArgs = submittedLookup
|
||||
? submittedLookup.kind === "skill"
|
||||
? { skillSlug: submittedLookup.value }
|
||||
: { packageName: submittedLookup.value }
|
||||
: "skip";
|
||||
const detail = useQuery(api.securityScanDigests.getStaffSecurityScanArtifact, detailArgs) as
|
||||
| SecurityScanArtifactDetail
|
||||
| undefined;
|
||||
|
||||
const kinds = useMemo(() => {
|
||||
if (!overview) return [] as ArtifactKind[];
|
||||
return (Object.keys(overview.current) as ArtifactKind[]).filter(
|
||||
(kind) => overview.current[kind],
|
||||
);
|
||||
}, [overview]);
|
||||
const combinedCurrent = useMemo(
|
||||
() => combineCounts(kinds, (kind) => overview?.current[kind]?.totals),
|
||||
[kinds, overview],
|
||||
);
|
||||
const combinedWindow = useMemo(
|
||||
() => combineCounts(kinds, (kind) => overview?.window.totalsByKind[kind]),
|
||||
[kinds, overview],
|
||||
);
|
||||
|
||||
const onSubmitLookup = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const value = lookupValue.trim();
|
||||
if (!value) return;
|
||||
setSubmittedLookup({ kind: lookupKind, value });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="mt-5 security-scan-overview">
|
||||
<div className="management-section-header">
|
||||
<div>
|
||||
<h2 className="section-title text-[1.2rem] m-0">Security scans</h2>
|
||||
<p className="section-subtitle m-0">
|
||||
ClawScan verdicts are primary. SkillSpector, static, and VirusTotal are evidence.
|
||||
</p>
|
||||
</div>
|
||||
{overview ? (
|
||||
<div className="management-count">Updated {formatTimestamp(overview.generatedAt)}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="management-controls security-scan-controls">
|
||||
<div className="security-scan-segment" aria-label="Artifact kind">
|
||||
{(["all", "skill", "plugin"] as ArtifactKindFilter[]).map((kind) => (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
className="security-scan-segment-button"
|
||||
aria-pressed={artifactKind === kind}
|
||||
onClick={() => setArtifactKind(kind)}
|
||||
>
|
||||
{kind === "all" ? "All" : formatArtifactKind(kind)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="management-control management-control-stack security-scan-window">
|
||||
<span className="mono">Window</span>
|
||||
<Select value={windowHours} onValueChange={setWindowHours}>
|
||||
<SelectTrigger className="management-field">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="24">Last 24 hours</SelectItem>
|
||||
<SelectItem value="72">Last 72 hours</SelectItem>
|
||||
<SelectItem value="168">Last 7 days</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!overview ? (
|
||||
<div className="stat security-scan-empty">Loading security scan overview...</div>
|
||||
) : combinedCurrent.total === 0 ? (
|
||||
<div className="stat security-scan-empty">No security scan digest rows yet.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="security-scan-metric-grid" aria-label="Security scan status summary">
|
||||
{PIPELINE_STATUSES.map((status) => (
|
||||
<Metric
|
||||
key={status}
|
||||
label={formatPipelineStatus(status)}
|
||||
value={combinedCurrent.byScanJobStatus[status]}
|
||||
detail={`${formatPercent(
|
||||
combinedCurrent.byScanJobStatus[status],
|
||||
combinedCurrent.total,
|
||||
)} of current artifacts`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="security-scan-grid">
|
||||
<section className="security-scan-panel">
|
||||
<div className="management-section-header">
|
||||
<h3 className="section-title text-[1rem] m-0">Current verdicts</h3>
|
||||
<span className="management-count">
|
||||
{formatNumber(combinedCurrent.total)} artifacts
|
||||
</span>
|
||||
</div>
|
||||
<div className="security-verdict-list">
|
||||
{VERDICTS.map((verdict) => (
|
||||
<VerdictRow
|
||||
key={verdict}
|
||||
verdict={verdict}
|
||||
count={combinedCurrent.byVerdict[verdict]}
|
||||
total={combinedCurrent.total}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="security-kind-breakdown">
|
||||
{kinds.map((kind) => {
|
||||
const totals = overview.current[kind]?.totals;
|
||||
if (!totals) return null;
|
||||
return (
|
||||
<div key={kind} className="security-kind-row">
|
||||
<span>{formatArtifactKind(kind)}</span>
|
||||
<span>{formatNumber(totals.total)}</span>
|
||||
<span>{formatPercent(totals.byVerdict.pass, totals.total)} pass</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="security-scan-panel">
|
||||
<div className="management-section-header">
|
||||
<h3 className="section-title text-[1rem] m-0">ClawScan categories</h3>
|
||||
<span className="management-count">Current primary category</span>
|
||||
</div>
|
||||
<CategoryRows overview={overview} kinds={kinds} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="security-scan-grid">
|
||||
<section className="security-scan-panel">
|
||||
<div className="management-section-header">
|
||||
<h3 className="section-title text-[1rem] m-0">
|
||||
Last {overview.window.hours} hours
|
||||
</h3>
|
||||
<span className="management-count">
|
||||
{formatNumber(combinedWindow.total)} scan events
|
||||
</span>
|
||||
</div>
|
||||
<div className="security-window-list">
|
||||
{overview.window.rows.length === 0 ? (
|
||||
<div className="stat">No scan events in this window.</div>
|
||||
) : (
|
||||
overview.window.rows.slice(0, 8).map((row) => (
|
||||
<div
|
||||
key={`${row.artifactKind}-${row.bucketStartMs}-${row.clawScanVerdict}-${row.scanJobStatus}-${row.failureStatus}`}
|
||||
className="security-window-row"
|
||||
>
|
||||
<span>{formatTimeBucket(row.bucketStartMs)}</span>
|
||||
<Badge>{formatArtifactKind(row.artifactKind)}</Badge>
|
||||
<span>{formatVerdict(row.clawScanVerdict)}</span>
|
||||
<span>{formatPipelineStatus(row.scanJobStatus)}</span>
|
||||
<strong>{formatNumber(row.count)}</strong>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="security-scan-panel">
|
||||
<div className="management-section-header">
|
||||
<h3 className="section-title text-[1rem] m-0">Failed scans</h3>
|
||||
<span className="management-count">Most recently updated</span>
|
||||
</div>
|
||||
<FailedScanRows rows={overview.failed.items} />
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<section className="security-scan-drilldown">
|
||||
<div className="management-section-header">
|
||||
<div>
|
||||
<h3 className="section-title text-[1rem] m-0">Artifact drilldown</h3>
|
||||
<p className="section-subtitle m-0">Inspect current ClawScan state and evidence.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form className="management-tool-grid" onSubmit={onSubmitLookup}>
|
||||
<label className="management-control management-control-stack">
|
||||
<span className="mono">Artifact</span>
|
||||
<Select
|
||||
value={lookupKind}
|
||||
onValueChange={(value) => setLookupKind(value as ArtifactKind)}
|
||||
>
|
||||
<SelectTrigger className="management-field">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="skill">Skill slug</SelectItem>
|
||||
<SelectItem value="plugin">Plugin package</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
<label className="management-control management-control-stack">
|
||||
<span className="mono">{lookupKind === "skill" ? "Slug" : "Package"}</span>
|
||||
<input
|
||||
className="management-field"
|
||||
value={lookupValue}
|
||||
onChange={(event) => setLookupValue(event.target.value)}
|
||||
placeholder={lookupKind === "skill" ? "agentic-risk-demo" : "@scope/plugin-name"}
|
||||
/>
|
||||
</label>
|
||||
<div className="management-control management-control-stack">
|
||||
<span className="mono">Action</span>
|
||||
<Button className="management-action-btn" type="submit" disabled={!lookupValue.trim()}>
|
||||
Inspect
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<DrilldownResult submitted={submittedLookup} detail={detail} />
|
||||
</section>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value, detail }: { label: string; value: number; detail: string }) {
|
||||
return (
|
||||
<div className="security-metric">
|
||||
<span>{label}</span>
|
||||
<strong>{formatNumber(value)}</strong>
|
||||
<small>{detail}</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VerdictRow({
|
||||
verdict,
|
||||
count,
|
||||
total,
|
||||
}: {
|
||||
verdict: ClawScanVerdict;
|
||||
count: number;
|
||||
total: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="security-verdict-row" data-verdict={verdict}>
|
||||
<span>{formatVerdict(verdict)}</span>
|
||||
<div className="security-verdict-bar" aria-hidden="true">
|
||||
<span style={{ width: `${Math.min(100, percentValue(count, total))}%` }} />
|
||||
</div>
|
||||
<strong>
|
||||
{formatNumber(count)}/{formatNumber(total)} ({formatPercent(count, total)})
|
||||
</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryRows({
|
||||
overview,
|
||||
kinds,
|
||||
}: {
|
||||
overview: SecurityScanOverviewResult;
|
||||
kinds: ArtifactKind[];
|
||||
}) {
|
||||
const categoryRows = kinds
|
||||
.flatMap((kind) => overview.current[kind]?.rollups ?? [])
|
||||
.filter((row) => row.rollupKind === "clawscanCategory" && row.categoryKey !== "all")
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 8);
|
||||
|
||||
if (categoryRows.length === 0) {
|
||||
return <div className="stat">No ClawScan category rollups yet.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="security-category-list">
|
||||
{categoryRows.map((row) => (
|
||||
<div
|
||||
key={`${row.artifactKind}-${row.categoryKey}-${row.clawScanVerdict}-${row.scanJobStatus}`}
|
||||
className="security-category-row"
|
||||
>
|
||||
<div>
|
||||
<strong>{row.categoryLabel ?? row.categoryKey}</strong>
|
||||
<span>
|
||||
{formatArtifactKind(row.artifactKind)} · {formatVerdict(row.clawScanVerdict)}
|
||||
</span>
|
||||
</div>
|
||||
<span>
|
||||
{formatNumber(row.count)}/{formatNumber(row.percentageBasis)} (
|
||||
{formatPercent(row.count, row.percentageBasis)})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FailedScanRows({ rows }: { rows: ArtifactStateSummary[] }) {
|
||||
if (rows.length === 0) {
|
||||
return <div className="stat">No failed scans.</div>;
|
||||
}
|
||||
return (
|
||||
<div className="security-failed-list">
|
||||
{rows.map((row) => (
|
||||
<div key={`${row.artifactKind}-${row.artifactKey}`} className="security-failed-row">
|
||||
<div>
|
||||
<strong>{row.displayName}</strong>
|
||||
<span>
|
||||
{formatArtifactKind(row.artifactKind)} · {row.version ? `v${row.version}` : "latest"}{" "}
|
||||
· {formatTimestamp(row.updatedAt)}
|
||||
</span>
|
||||
{row.lastError ? <small>{row.lastError}</small> : null}
|
||||
</div>
|
||||
{row.artifactKind === "skill" && row.slug ? (
|
||||
<Button asChild size="sm">
|
||||
<Link to="/management" search={{ skill: row.slug, plugin: undefined }}>
|
||||
Inspect
|
||||
</Link>
|
||||
</Button>
|
||||
) : row.artifactKind === "plugin" && row.name ? (
|
||||
<Button asChild size="sm">
|
||||
<Link to="/management" search={{ skill: undefined, plugin: row.name }}>
|
||||
Inspect
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DrilldownResult({
|
||||
submitted,
|
||||
detail,
|
||||
}: {
|
||||
submitted: { kind: ArtifactKind; value: string } | null;
|
||||
detail: SecurityScanArtifactDetail | undefined;
|
||||
}) {
|
||||
if (!submitted) {
|
||||
return (
|
||||
<div className="stat security-scan-empty">Enter a skill slug or plugin package name.</div>
|
||||
);
|
||||
}
|
||||
if (detail === undefined) {
|
||||
return <div className="stat security-scan-empty">Loading scan detail...</div>;
|
||||
}
|
||||
if (!detail.found) {
|
||||
return (
|
||||
<div className="stat security-scan-empty">
|
||||
No {formatArtifactKind(submitted.kind).toLowerCase()} found for "{submitted.value}".
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const state = detail.state;
|
||||
const evidence = detail.evidence;
|
||||
const artifactDisplayName =
|
||||
state?.displayName ??
|
||||
detail.artifact?.skill?.displayName ??
|
||||
detail.artifact?.package?.displayName ??
|
||||
submitted.value;
|
||||
const artifactVersion =
|
||||
state?.version ?? detail.artifact?.version?.version ?? detail.artifact?.release?.version;
|
||||
return (
|
||||
<div className="security-drilldown-result">
|
||||
<div className="security-drilldown-heading">
|
||||
<div>
|
||||
<strong>{artifactDisplayName}</strong>
|
||||
<span>
|
||||
{formatArtifactKind(detail.artifactKind)} ·{" "}
|
||||
{artifactVersion ? `v${artifactVersion}` : "latest"} ·{" "}
|
||||
{state ? formatVerdict(state.clawScanVerdict) : "No digest state"}
|
||||
</span>
|
||||
</div>
|
||||
{state ? (
|
||||
<Badge>{formatPipelineStatus(state.scanJobStatus)}</Badge>
|
||||
) : (
|
||||
<Badge>No digest row</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="security-evidence-grid">
|
||||
<EvidenceBlock
|
||||
title="ClawScan"
|
||||
rows={[
|
||||
["Verdict", evidence?.clawScan.verdict ?? state?.clawScanVerdict ?? "unknown"],
|
||||
["Status", evidence?.clawScan.status ?? state?.clawScanStatus ?? "unknown"],
|
||||
["Confidence", evidence?.clawScan.confidence ?? "n/a"],
|
||||
[
|
||||
"Category",
|
||||
state?.clawScanPrimaryCategoryLabel ?? state?.clawScanPrimaryCategoryKey ?? "n/a",
|
||||
],
|
||||
["Summary", evidence?.clawScan.summary ?? state?.clawScanSummary ?? "No summary."],
|
||||
]}
|
||||
/>
|
||||
<EvidenceBlock
|
||||
title="SkillSpector"
|
||||
rows={[
|
||||
["Status", evidence?.skillSpector.status ?? state?.skillSpectorStatus ?? "n/a"],
|
||||
[
|
||||
"Score",
|
||||
evidence?.skillSpector.score !== undefined
|
||||
? String(evidence.skillSpector.score)
|
||||
: state?.skillSpectorScore !== undefined
|
||||
? String(state.skillSpectorScore)
|
||||
: "n/a",
|
||||
],
|
||||
["Severity", evidence?.skillSpector.severity ?? state?.skillSpectorSeverity ?? "n/a"],
|
||||
[
|
||||
"Issues",
|
||||
String(evidence?.skillSpector.issueCount ?? state?.skillSpectorIssueCount ?? 0),
|
||||
],
|
||||
]}
|
||||
/>
|
||||
<EvidenceBlock
|
||||
title="Other scanners"
|
||||
rows={[
|
||||
["Static", evidence?.staticScan?.status ?? state?.staticStatus ?? "n/a"],
|
||||
[
|
||||
"Static reasons",
|
||||
String(state?.staticReasonCount ?? evidence?.staticScan?.reasonCodes?.length ?? 0),
|
||||
],
|
||||
[
|
||||
"VirusTotal",
|
||||
evidence?.virusTotal?.verdict ?? state?.vtVerdict ?? state?.vtStatus ?? "n/a",
|
||||
],
|
||||
[
|
||||
"VT detections",
|
||||
`${state?.vtMalicious ?? evidence?.virusTotal?.malicious ?? 0} malicious / ${
|
||||
state?.vtSuspicious ?? evidence?.virusTotal?.suspicious ?? 0
|
||||
} suspicious`,
|
||||
],
|
||||
]}
|
||||
/>
|
||||
<EvidenceBlock
|
||||
title="Worker"
|
||||
rows={[
|
||||
["Job", detail.scanJob?._id ?? "n/a"],
|
||||
["Source", detail.scanJob?.source ?? "n/a"],
|
||||
["Attempts", String(detail.scanJob?.attempts ?? state?.lastScanAttempts ?? 0)],
|
||||
["Worker", detail.scanJob?.workerId ?? state?.lastScanWorkerId ?? "n/a"],
|
||||
["Error", detail.scanJob?.lastError ?? state?.lastError ?? "None"],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{evidence?.skillSpector.issues.length ? (
|
||||
<div className="management-sublist security-issue-list">
|
||||
<div className="section-subtitle m-0">SkillSpector issues</div>
|
||||
{evidence.skillSpector.issues.slice(0, 5).map((issue, index) => (
|
||||
<div key={issue.issueId ?? index} className="management-report-item">
|
||||
<span className="management-report-meta">
|
||||
{issue.severity ?? "unknown"} · {issue.issueId ?? `issue ${index + 1}`}
|
||||
</span>
|
||||
<span>{issue.finding ?? issue.explanation ?? "No explanation."}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EvidenceBlock({ title, rows }: { title: string; rows: Array<[string, string]> }) {
|
||||
return (
|
||||
<section className="security-evidence-block">
|
||||
<h4>{title}</h4>
|
||||
{rows.map(([label, value]) => (
|
||||
<div key={label} className="management-report-item">
|
||||
<span className="management-report-meta">{label}</span>
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function combineCounts(
|
||||
kinds: ArtifactKind[],
|
||||
getCounts: (kind: ArtifactKind) => CountBucket | undefined,
|
||||
): CountBucket {
|
||||
const combined = makeEmptyCounts();
|
||||
for (const kind of kinds) {
|
||||
const counts = getCounts(kind);
|
||||
if (!counts) continue;
|
||||
combined.total += counts.total;
|
||||
for (const verdict of VERDICTS) combined.byVerdict[verdict] += counts.byVerdict[verdict] ?? 0;
|
||||
for (const status of ["none", ...PIPELINE_STATUSES] as PipelineStatus[]) {
|
||||
combined.byScanJobStatus[status] += counts.byScanJobStatus[status] ?? 0;
|
||||
}
|
||||
combined.byFailureStatus.none += counts.byFailureStatus.none ?? 0;
|
||||
combined.byFailureStatus.failed += counts.byFailureStatus.failed ?? 0;
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
|
||||
function makeEmptyCounts(): CountBucket {
|
||||
return {
|
||||
total: 0,
|
||||
byVerdict: {
|
||||
pass: 0,
|
||||
suspicious: 0,
|
||||
malicious: 0,
|
||||
pending: 0,
|
||||
failed: 0,
|
||||
unknown: 0,
|
||||
},
|
||||
byScanJobStatus: {
|
||||
none: 0,
|
||||
queued: 0,
|
||||
running: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
},
|
||||
byFailureStatus: {
|
||||
none: 0,
|
||||
failed: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatArtifactKind(kind: ArtifactKind) {
|
||||
return kind === "skill" ? "Skills" : "Plugins";
|
||||
}
|
||||
|
||||
function formatVerdict(verdict: string) {
|
||||
return verdict.charAt(0).toUpperCase() + verdict.slice(1);
|
||||
}
|
||||
|
||||
function formatPipelineStatus(status: string) {
|
||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||
}
|
||||
|
||||
function formatNumber(value: number) {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
function percentValue(count: number, total: number) {
|
||||
if (total <= 0) return 0;
|
||||
return (count / total) * 100;
|
||||
}
|
||||
|
||||
function formatPercent(count: number, total: number) {
|
||||
return `${Math.round(percentValue(count, total))}%`;
|
||||
}
|
||||
|
||||
function formatTimestamp(value: number) {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function formatTimeBucket(value: number) {
|
||||
return new Date(value).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const useAuthStatusMock = vi.fn();
|
||||
const useQueryMock = vi.fn();
|
||||
const useMutationMock = vi.fn();
|
||||
const navigateMock = vi.fn();
|
||||
let searchMock: { skill?: string; plugin?: string } = {};
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: () => (config: { component?: ComponentType }) => ({
|
||||
__config: config,
|
||||
useSearch: () => searchMock,
|
||||
}),
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
to?: string;
|
||||
search?: Record<string, string | undefined>;
|
||||
}) => <a href={to ?? "/"}>{children}</a>,
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
useMutation: (...args: unknown[]) => useMutationMock(...args),
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => useAuthStatusMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../components/management/SecurityScanOverview", () => ({
|
||||
SecurityScanOverview: () => <div>Security overview marker</div>,
|
||||
}));
|
||||
|
||||
async function loadManagementRoute() {
|
||||
return (await import("./management")).Route as unknown as {
|
||||
__config: {
|
||||
component: ComponentType;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
describe("management route", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
searchMock = {};
|
||||
navigateMock.mockReset();
|
||||
useMutationMock.mockReset();
|
||||
useMutationMock.mockReturnValue(vi.fn());
|
||||
useQueryMock.mockReset();
|
||||
useQueryMock.mockImplementation((_query: unknown, args: unknown) =>
|
||||
args === "skip" ? undefined : [],
|
||||
);
|
||||
useAuthStatusMock.mockReset();
|
||||
});
|
||||
|
||||
it("hides the management console from ordinary users", async () => {
|
||||
useAuthStatusMock.mockReturnValue({ me: { _id: "users:reader", role: "user" } });
|
||||
const route = await loadManagementRoute();
|
||||
const Component = route.__config.component;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getByText("Management only.")).toBeTruthy();
|
||||
expect(screen.queryByText("Security overview marker")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the security scan overview inside the staff management console", async () => {
|
||||
useAuthStatusMock.mockReturnValue({ me: { _id: "users:moderator", role: "moderator" } });
|
||||
const route = await loadManagementRoute();
|
||||
const Component = route.__config.component;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getByText("Management console")).toBeTruthy();
|
||||
expect(screen.getByText("Security overview marker")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { useMutation, useQuery } from "convex/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { SecurityScanOverview } from "../components/management/SecurityScanOverview";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card } from "../components/ui/card";
|
||||
@@ -301,6 +302,11 @@ function Management() {
|
||||
<h1 className="section-title">Management console</h1>
|
||||
<p className="section-subtitle">Moderation, curation, and ownership tools.</p>
|
||||
|
||||
<SecurityScanOverview
|
||||
selectedSkillSlug={selectedSlug}
|
||||
selectedPluginName={selectedPluginName}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<h2 className="section-title text-[1.2rem] m-0">Reported skills</h2>
|
||||
<div className="management-controls">
|
||||
|
||||
+273
@@ -8046,6 +8046,254 @@ code {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.management-section-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.security-scan-overview {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.security-scan-controls {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.security-scan-segment {
|
||||
display: inline-flex;
|
||||
min-height: 42px;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
background: color-mix(in srgb, var(--surface) 82%, black 3%);
|
||||
}
|
||||
|
||||
.security-scan-segment-button {
|
||||
min-width: 76px;
|
||||
border: 0;
|
||||
border-radius: calc(var(--r-sm) - 3px);
|
||||
padding: 8px 12px;
|
||||
color: var(--ink-soft);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.security-scan-segment-button[aria-pressed="true"] {
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 1px 6px rgba(29, 59, 78, 0.12);
|
||||
}
|
||||
|
||||
.security-scan-window {
|
||||
min-width: 190px;
|
||||
}
|
||||
|
||||
.security-scan-metric-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.security-metric {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
background: color-mix(in srgb, var(--surface) 86%, transparent);
|
||||
}
|
||||
|
||||
.security-metric span,
|
||||
.security-metric small {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.security-metric strong {
|
||||
font-size: 1.35rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.security-scan-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.security-scan-panel,
|
||||
.security-scan-drilldown,
|
||||
.security-drilldown-result,
|
||||
.security-evidence-block {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
background: color-mix(in srgb, var(--surface) 72%, transparent);
|
||||
}
|
||||
|
||||
.security-verdict-list,
|
||||
.security-category-list,
|
||||
.security-window-list,
|
||||
.security-failed-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.security-verdict-row {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: 88px minmax(80px, 1fr) minmax(100px, auto);
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.security-verdict-bar {
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--line) 72%, transparent);
|
||||
}
|
||||
|
||||
.security-verdict-bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.security-verdict-row[data-verdict="pass"] .security-verdict-bar span {
|
||||
background: #2f8f5b;
|
||||
}
|
||||
|
||||
.security-verdict-row[data-verdict="suspicious"] .security-verdict-bar span,
|
||||
.security-verdict-row[data-verdict="pending"] .security-verdict-bar span {
|
||||
background: #b47515;
|
||||
}
|
||||
|
||||
.security-verdict-row[data-verdict="malicious"] .security-verdict-bar span,
|
||||
.security-verdict-row[data-verdict="failed"] .security-verdict-bar span {
|
||||
background: #b83f35;
|
||||
}
|
||||
|
||||
.security-kind-breakdown {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--line) 72%, transparent);
|
||||
}
|
||||
|
||||
.security-kind-row,
|
||||
.security-category-row,
|
||||
.security-window-row,
|
||||
.security-failed-row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.security-kind-row {
|
||||
grid-template-columns: 1fr auto auto;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.security-category-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid color-mix(in srgb, var(--line) 68%, transparent);
|
||||
}
|
||||
|
||||
.security-category-row:first-child {
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.security-category-row div,
|
||||
.security-failed-row div,
|
||||
.security-drilldown-heading div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.security-category-row span,
|
||||
.security-failed-row span,
|
||||
.security-drilldown-heading span {
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.security-window-row {
|
||||
grid-template-columns: minmax(88px, auto) auto 1fr auto auto;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid color-mix(in srgb, var(--line) 68%, transparent);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.security-window-row:first-child {
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.security-failed-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid color-mix(in srgb, var(--line) 68%, transparent);
|
||||
}
|
||||
|
||||
.security-failed-row:first-child {
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.security-failed-row small {
|
||||
color: var(--ink-soft);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.security-drilldown-result {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.security-drilldown-heading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.security-evidence-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.security-evidence-block h4 {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.security-evidence-block span {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.security-issue-list {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.security-scan-empty {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.management-item {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -8058,6 +8306,31 @@ code {
|
||||
.management-subitem {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.security-scan-metric-grid,
|
||||
.security-scan-grid,
|
||||
.security-evidence-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.security-window-row,
|
||||
.security-failed-row,
|
||||
.security-kind-row,
|
||||
.security-category-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.security-scan-segment,
|
||||
.security-scan-segment-button,
|
||||
.security-scan-window {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.security-verdict-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Security Scan Results */
|
||||
|
||||
Reference in New Issue
Block a user