fix: retire dependency registry scans

This commit is contained in:
Jesse Merhi
2026-06-09 22:28:13 +10:00
committed by GitHub
parent 8970a46acf
commit c0143b1cd1
15 changed files with 1130 additions and 829 deletions
-2
View File
@@ -57,7 +57,6 @@ import type * as lib_changelog from "../lib/changelog.js";
import type * as lib_clawpack from "../lib/clawpack.js";
import type * as lib_commentScamPrompt from "../lib/commentScamPrompt.js";
import type * as lib_contentTypes from "../lib/contentTypes.js";
import type * as lib_depRegistryScan from "../lib/depRegistryScan.js";
import type * as lib_devAuth from "../lib/devAuth.js";
import type * as lib_devSeed from "../lib/devSeed.js";
import type * as lib_emails from "../lib/emails.js";
@@ -213,7 +212,6 @@ declare const fullApi: ApiFromModules<{
"lib/clawpack": typeof lib_clawpack;
"lib/commentScamPrompt": typeof lib_commentScamPrompt;
"lib/contentTypes": typeof lib_contentTypes;
"lib/depRegistryScan": typeof lib_depRegistryScan;
"lib/devAuth": typeof lib_devAuth;
"lib/devSeed": typeof lib_devSeed;
"lib/emails": typeof lib_emails;
+10
View File
@@ -0,0 +1,10 @@
/* @vitest-environment node */
import { describe, expect, it } from "vitest";
const { checkDependencyRegistriesHandler } = await import("./depRegistryScan");
describe("dependency registry scan drain", () => {
it("keeps legacy scheduled jobs harmless after the scanner is retired", async () => {
await expect(checkDependencyRegistriesHandler()).resolves.toBeNull();
});
});
+4 -263
View File
@@ -1,270 +1,11 @@
import { v } from "convex/values";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import type { ActionCtx } from "./_generated/server";
import { internalAction, internalMutation, internalQuery } from "./functions";
import {
dedupeDeps,
depRegistryUrl,
parseDependencyFile,
SUPPORTED_DEP_REGISTRIES,
summarizeDepRegistryChecks,
type DepEntry,
type DepRegistryResult,
type DepRegistryUnresolved,
type SupportedDepRegistry,
} from "./lib/depRegistryScan";
import { readStorageText } from "./lib/packageRegistry";
import { internalAction } from "./_generated/server";
const REQUEST_TIMEOUT_MS = 8_000;
const MAX_RETRIES = 2;
const BACKOFF_BASE_MS = 750;
const INTER_REQUEST_DELAY_MS = 100;
const MAX_DEPENDENCIES_PER_SCAN = 120;
const CACHE_TTL_EXISTS_MS = 30 * 24 * 60 * 60 * 1_000;
const CACHE_TTL_NOT_EXISTS_MS = 7 * 24 * 60 * 60 * 1_000;
const registryValidator = v.union(v.literal("pypi"), v.literal("npm"), v.literal("cargo"));
type RegistryCheck =
| { kind: "found"; httpStatus: number }
| { kind: "missing"; httpStatus: number }
| { kind: "unresolved"; reason: string };
function isSupportedRegistry(value: string): value is SupportedDepRegistry {
return (SUPPORTED_DEP_REGISTRIES as readonly string[]).includes(value);
}
async function wait(ms: number) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function checkRegistry(dep: DepEntry): Promise<RegistryCheck> {
const headers: Record<string, string> = { Accept: "application/json" };
if (dep.registry === "cargo") {
headers["User-Agent"] = "ClawHub-DepRegistryScan/1.0 (https://clawhub.ai)";
}
let lastStatus: number | undefined;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(depRegistryUrl(dep.registry, dep.name), {
method: "GET",
headers,
signal: controller.signal,
});
clearTimeout(timeout);
lastStatus = response.status;
if (response.status === 200) return { kind: "found", httpStatus: response.status };
if (response.status === 404) return { kind: "missing", httpStatus: response.status };
if (response.status !== 429 && response.status < 500) {
return {
kind: "unresolved",
reason: `unexpected HTTP ${response.status}`,
};
}
} catch (error) {
clearTimeout(timeout);
if (attempt === MAX_RETRIES) {
return {
kind: "unresolved",
reason: error instanceof Error ? error.message : "network error",
};
}
}
if (attempt < MAX_RETRIES) {
await wait(2 ** attempt * BACKOFF_BASE_MS);
}
}
return {
kind: "unresolved",
reason: lastStatus ? `HTTP ${lastStatus}` : "network error",
};
}
async function extractDependencies(ctx: Pick<ActionCtx, "storage">, version: Doc<"skillVersions">) {
const entries: DepEntry[] = [];
for (const file of version.files) {
const basename = file.path.split("/").pop()?.toLowerCase() ?? "";
if (
basename !== "requirements.txt" &&
basename !== "requirements-dev.txt" &&
basename !== "requirements_dev.txt" &&
basename !== "requirements-test.txt" &&
basename !== "requirements_test.txt" &&
basename !== "package.json" &&
basename !== "cargo.toml" &&
basename !== "pyproject.toml"
) {
continue;
}
const content = await readStorageText(ctx, file.storageId);
entries.push(...parseDependencyFile(file.path, content));
}
return dedupeDeps(entries);
}
export const lookupCacheInternal = internalQuery({
args: {
registry: registryValidator,
name: v.string(),
},
handler: async (ctx, args): Promise<Doc<"depRegistryCache"> | null> => {
return ctx.db
.query("depRegistryCache")
.withIndex("by_registry_name", (q) => q.eq("registry", args.registry).eq("name", args.name))
.unique();
},
});
export const upsertCacheInternal = internalMutation({
args: {
registry: registryValidator,
name: v.string(),
exists: v.boolean(),
httpStatus: v.number(),
checkedAt: v.number(),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query("depRegistryCache")
.withIndex("by_registry_name", (q) => q.eq("registry", args.registry).eq("name", args.name))
.unique();
const patch = {
registry: args.registry,
name: args.name,
exists: args.exists,
httpStatus: args.httpStatus,
checkedAt: args.checkedAt,
};
if (existing) {
await ctx.db.patch(existing._id, patch);
} else {
await ctx.db.insert("depRegistryCache", patch);
}
},
});
export const getRetryableVersionIdsInternal = internalQuery({
args: {
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const limit = Math.min(Math.max(args.limit ?? 25, 1), 100);
const versions = await ctx.db
.query("skillVersions")
.withIndex("by_dep_registry_scan_status_and_created", (q) =>
q.eq("depRegistryScanStatus", "error"),
)
.order("desc")
.take(limit);
return versions.map((version) => version._id);
},
});
async function checkWithCache(ctx: ActionCtx, dep: DepEntry) {
const now = Date.now();
const cached = (await ctx.runQuery(internal.depRegistryScan.lookupCacheInternal, {
registry: dep.registry,
name: dep.name,
})) as Doc<"depRegistryCache"> | null;
if (cached) {
const ttl = cached.exists ? CACHE_TTL_EXISTS_MS : CACHE_TTL_NOT_EXISTS_MS;
if (now - cached.checkedAt < ttl) {
return cached.exists
? ({ kind: "found", httpStatus: cached.httpStatus } as const)
: ({ kind: "missing", httpStatus: cached.httpStatus } as const);
}
}
const check = await checkRegistry(dep);
if (check.kind !== "unresolved") {
await ctx.runMutation(internal.depRegistryScan.upsertCacheInternal, {
registry: dep.registry,
name: dep.name,
exists: check.kind === "found",
httpStatus: check.httpStatus,
checkedAt: now,
});
}
return check;
export async function checkDependencyRegistriesHandler(): Promise<null> {
return null;
}
export const checkDependencyRegistries = internalAction({
args: { versionId: v.id("skillVersions") },
handler: async (ctx, args) => {
const version = (await ctx.runQuery(internal.skills.getVersionByIdInternal, {
versionId: args.versionId,
})) as Doc<"skillVersions"> | null;
if (!version) return null;
if (version.depRegistryAnalysis && version.depRegistryAnalysis.status !== "error") {
return version.depRegistryAnalysis;
}
const deps = await extractDependencies(ctx, version);
const checkableDeps = deps.slice(0, MAX_DEPENDENCIES_PER_SCAN);
const deferredDeps = deps.slice(MAX_DEPENDENCIES_PER_SCAN);
const results: DepRegistryResult[] = [];
const unresolved: DepRegistryUnresolved[] = deferredDeps.map((dep) => ({
...dep,
reason: "dependency scan limit reached",
}));
for (const dep of checkableDeps) {
if (!isSupportedRegistry(dep.registry)) continue;
const check = await checkWithCache(ctx, dep);
if (check.kind === "unresolved") {
unresolved.push({ ...dep, reason: check.reason });
} else {
results.push({
...dep,
exists: check.kind === "found",
httpStatus: check.httpStatus,
});
}
await wait(INTER_REQUEST_DELAY_MS);
}
const analysis = summarizeDepRegistryChecks({
results,
unresolved,
checkedAt: Date.now(),
});
await ctx.runMutation(internal.skills.updateVersionDepRegistryAnalysisInternal, {
versionId: args.versionId,
depRegistryAnalysis: analysis,
});
return analysis;
},
});
export const rescanErrorDepRegistryVersions = internalAction({
args: {
batchSize: v.optional(v.number()),
},
handler: async (ctx, args) => {
const versionIds = (await ctx.runQuery(
internal.depRegistryScan.getRetryableVersionIdsInternal,
{ limit: args.batchSize ?? 25 },
)) as Id<"skillVersions">[];
let scheduled = 0;
for (const versionId of versionIds) {
await ctx.scheduler.runAfter(
scheduled * 2_000,
internal.depRegistryScan.checkDependencyRegistries,
{
versionId,
},
);
scheduled += 1;
}
return { scheduled };
},
handler: checkDependencyRegistriesHandler,
});
+30 -30
View File
@@ -4675,6 +4675,15 @@ describe("httpApiV1 handlers", () => {
engineVersion: "static-v1",
checkedAt: 2,
},
depRegistryAnalysis: {
status: "suspicious",
results: [],
notFoundPackages: ["left-pad (npm)"],
unresolvedPackages: [],
summary: "Legacy dependency registry warning.",
checkedAt: 9,
},
depRegistryScanStatus: "suspicious",
llmAnalysis: {
status: "clean",
verdict: "clean",
@@ -4683,14 +4692,6 @@ describe("httpApiV1 handlers", () => {
checkedAt: 3,
model: "gpt-test",
},
depRegistryAnalysis: {
status: "clean",
results: [],
notFoundPackages: [],
unresolvedPackages: [],
summary: "No dependency issues.",
checkedAt: 4,
},
capabilityTags: ["dev-tools"],
softDeletedAt: undefined,
};
@@ -4743,7 +4744,7 @@ describe("httpApiV1 handlers", () => {
requestedVersion: "1.0.0",
version: "1.0.0",
createdAt: 1,
checkedAt: 4,
checkedAt: 3,
skillUrl: "https://example.com/acme/demo",
securityAuditUrl: "https://example.com/acme/demo/security-audit?version=1.0.0",
security: {
@@ -4753,7 +4754,7 @@ describe("httpApiV1 handlers", () => {
verdict: "clean",
signals: {
staticScan: { status: "clean", rawStatus: "clean" },
dependencyRegistry: { status: "clean", rawStatus: "clean" },
dependencyRegistry: null,
},
},
},
@@ -4762,7 +4763,13 @@ describe("httpApiV1 handlers", () => {
expect(json.items[0].card).toBeUndefined();
expect(json.items[0].artifact).toBeUndefined();
expect(json.items[0].security.signals.staticScan.findings).toBeUndefined();
expect(json.items[0].security.signals.dependencyRegistry.notFoundPackages).toBeUndefined();
expect(Object.keys(json.items[0].security.signals)).toEqual([
"staticScan",
"virusTotal",
"skillSpector",
"dependencyRegistry",
]);
expect(json.items[0].security.signals.dependencyRegistry).toBeNull();
expect(runQuery.mock.calls.map(([, args]) => args)).toContainEqual({
slug: "demo",
version: "1.0.0",
@@ -5141,6 +5148,15 @@ describe("httpApiV1 handlers", () => {
source: "engines",
checkedAt: 4,
},
depRegistryAnalysis: {
status: "suspicious",
results: [],
notFoundPackages: ["left-pad (npm)"],
unresolvedPackages: [],
summary: "Legacy dependency registry warning.",
checkedAt: 9,
},
depRegistryScanStatus: "suspicious",
skillSpectorAnalysis: {
status: "clean",
score: 0,
@@ -5152,14 +5168,6 @@ describe("httpApiV1 handlers", () => {
summary: "SkillSpector clean.",
checkedAt: 5,
},
depRegistryAnalysis: {
status: "clean",
results: [],
notFoundPackages: [],
unresolvedPackages: [],
summary: "No dependency issues.",
checkedAt: 6,
},
capabilityTags: ["dev-tools"],
softDeletedAt: undefined,
};
@@ -5256,7 +5264,7 @@ describe("httpApiV1 handlers", () => {
recommendation: "INSTALL",
issueCount: 0,
},
dependencyRegistry: { status: "clean" },
dependencyRegistry: null,
},
},
signature: { status: "unsigned" },
@@ -5415,7 +5423,7 @@ describe("httpApiV1 handlers", () => {
expect(json.security).toMatchObject({ status: "clean", passed: true });
});
it("passes verification when static and dependency findings are advisory but ClawScan is clean", async () => {
it("passes verification when static findings are advisory but ClawScan is clean", async () => {
const internalVersion = {
_id: "skillVersions:1",
skillId: "skills:1",
@@ -5442,14 +5450,6 @@ describe("httpApiV1 handlers", () => {
summary: "ClawScan clean.",
checkedAt: 3,
},
depRegistryAnalysis: {
status: "malicious",
results: [],
notFoundPackages: ["left-pad"],
unresolvedPackages: [],
summary: "Dependency advisory warning.",
checkedAt: 4,
},
softDeletedAt: undefined,
};
const generatedBundleFingerprint = await buildBundleFingerprint(internalVersion.files);
@@ -5498,7 +5498,7 @@ describe("httpApiV1 handlers", () => {
verdict: "benign",
signals: {
staticScan: { status: "malicious", rawStatus: "malicious" },
dependencyRegistry: { status: "malicious", rawStatus: "malicious" },
dependencyRegistry: null,
},
});
});
+2 -32
View File
@@ -664,16 +664,6 @@ type VerifySecurityVersion = {
| "checkedAt"
> &
Partial<Pick<NonNullable<Doc<"skillVersions">["skillSpectorAnalysis"]>, "summary" | "error">>;
depRegistryAnalysis?: Pick<
NonNullable<Doc<"skillVersions">["depRegistryAnalysis"]>,
"status" | "summary" | "checkedAt"
> &
Partial<
Pick<
NonNullable<Doc<"skillVersions">["depRegistryAnalysis"]>,
"notFoundPackages" | "unresolvedPackages"
>
>;
};
type SecurityVerdictTargetResult = {
@@ -723,9 +713,6 @@ function buildVerifySecurity(version: VerifySecurityVersion) {
const skillSpectorStatus = version.skillSpectorAnalysis
? normalizeVerificationStatus(version.skillSpectorAnalysis.status)
: null;
const depStatus = version.depRegistryAnalysis
? normalizeVerificationStatus(version.depRegistryAnalysis.status)
: null;
const status = clawStatus;
return {
@@ -781,16 +768,7 @@ function buildVerifySecurity(version: VerifySecurityVersion) {
checkedAt: version.skillSpectorAnalysis.checkedAt ?? null,
}
: null,
dependencyRegistry: version.depRegistryAnalysis
? {
status: depStatus ?? "pending",
rawStatus: version.depRegistryAnalysis.status,
summary: version.depRegistryAnalysis.summary ?? null,
notFoundPackages: version.depRegistryAnalysis.notFoundPackages ?? [],
unresolvedPackages: version.depRegistryAnalysis.unresolvedPackages ?? [],
checkedAt: version.depRegistryAnalysis.checkedAt ?? null,
}
: null,
dependencyRegistry: null,
},
};
}
@@ -857,7 +835,6 @@ function getVerifySecurityCheckedAt(security: ReturnType<typeof buildVerifySecur
security.signals.staticScan?.checkedAt,
security.signals.virusTotal?.checkedAt,
security.signals.skillSpector?.checkedAt,
security.signals.dependencyRegistry?.checkedAt,
].filter((value): value is number => typeof value === "number");
return candidates.length > 0 ? Math.max(...candidates) : null;
}
@@ -904,14 +881,7 @@ function buildSecurityVerdictSummary(security: ReturnType<typeof buildVerifySecu
checkedAt: security.signals.skillSpector.checkedAt,
}
: null,
dependencyRegistry: security.signals.dependencyRegistry
? {
status: security.signals.dependencyRegistry.status,
rawStatus: security.signals.dependencyRegistry.rawStatus,
summary: security.signals.dependencyRegistry.summary,
checkedAt: security.signals.dependencyRegistry.checkedAt,
}
: null,
dependencyRegistry: null,
},
};
}
-100
View File
@@ -1,100 +0,0 @@
import { describe, expect, it } from "vitest";
import {
depRegistryUrl,
mergeDepRegistryFinding,
parseDependencyFile,
summarizeDepRegistryChecks,
} from "./depRegistryScan";
import { summarizeReasonCodes, verdictFromCodes } from "./moderationReasonCodes";
describe("depRegistryScan", () => {
it("parses registry dependency manifests and skips vendored or non-registry specs", () => {
expect(
parseDependencyFile(
"package.json",
JSON.stringify({
dependencies: {
"@types/node": "^24.0.0",
local: "file:../local",
remote: "github:owner/repo",
},
optionalDependencies: {
undici: "^7.0.0",
},
}),
),
).toEqual([
{ name: "@types/node", registry: "npm", source: "package.json" },
{ name: "undici", registry: "npm", source: "package.json" },
]);
expect(
parseDependencyFile("vendor/package.json", '{"dependencies":{"phantom":"1.0.0"}}'),
).toEqual([]);
expect(
parseDependencyFile(
"requirements.txt",
["requests>=2", "demo @ git+https://example.test/demo.git", "-r dev.txt"].join("\n"),
),
).toEqual([{ name: "requests", registry: "pypi", source: "requirements.txt" }]);
});
it("keeps npm scope names compatible with registry URL lookup", () => {
expect(depRegistryUrl("npm", "@types/node")).toBe("https://registry.npmjs.org/@types%2Fnode");
});
it("does not produce clean status when registry lookups are unresolved", () => {
const analysis = summarizeDepRegistryChecks({
checkedAt: 123,
results: [{ name: "requests", registry: "pypi", source: "requirements.txt", exists: true }],
unresolved: [
{
name: "maybe-real",
registry: "npm",
source: "package.json",
reason: "network error",
},
],
});
expect(analysis.status).toBe("error");
expect(analysis.notFoundPackages).toEqual([]);
expect(analysis.unresolvedPackages).toEqual(["maybe-real (npm)"]);
});
it("injects a static finding only for confirmed missing packages", () => {
const suspicious = summarizeDepRegistryChecks({
checkedAt: 456,
results: [
{
name: "phantom-package-xyz",
registry: "npm",
source: "package.json",
exists: false,
httpStatus: 404,
},
],
unresolved: [],
});
const merged = mergeDepRegistryFinding({
staticScan: undefined,
analysis: suspicious,
statusFromCodes: verdictFromCodes,
summarizeCodes: summarizeReasonCodes,
});
expect(merged.status).toBe("suspicious");
expect(merged.reasonCodes).toEqual(["suspicious.dep_not_found_on_registry"]);
expect(merged.findings[0]?.file).toBe("Dependency manifests");
const cleanAgain = mergeDepRegistryFinding({
staticScan: merged,
analysis: summarizeDepRegistryChecks({ checkedAt: 789, results: [], unresolved: [] }),
statusFromCodes: verdictFromCodes,
summarizeCodes: summarizeReasonCodes,
});
expect(cleanAgain.status).toBe("clean");
expect(cleanAgain.findings).toEqual([]);
});
});
-321
View File
@@ -1,321 +0,0 @@
import {
MODERATION_ENGINE_VERSION,
REASON_CODES,
type ModerationFinding,
type ModerationVerdict,
} from "./moderationReasonCodes";
export const SUPPORTED_DEP_REGISTRIES = ["pypi", "npm", "cargo"] as const;
export type SupportedDepRegistry = (typeof SUPPORTED_DEP_REGISTRIES)[number];
export type DepRegistryStatus = "clean" | "suspicious" | "error";
export type DepEntry = {
name: string;
registry: SupportedDepRegistry;
source: string;
};
export type DepRegistryResult = DepEntry & {
exists: boolean;
httpStatus?: number;
};
export type DepRegistryUnresolved = DepEntry & {
reason: string;
};
export type DepRegistryAnalysis = {
status: DepRegistryStatus;
results: DepRegistryResult[];
notFoundPackages: string[];
unresolvedPackages: string[];
summary: string;
checkedAt: number;
};
const DEP_FILE_PARSERS: Record<string, (content: string, path: string) => DepEntry[]> = {
"requirements.txt": parseRequirementsTxt,
"requirements-dev.txt": parseRequirementsTxt,
"requirements_dev.txt": parseRequirementsTxt,
"requirements-test.txt": parseRequirementsTxt,
"requirements_test.txt": parseRequirementsTxt,
"package.json": parsePackageJson,
"cargo.toml": parseCargoToml,
"pyproject.toml": parsePyprojectToml,
};
const NON_REGISTRY_NPM_SPEC_PREFIXES = [
"file:",
"link:",
"git+",
"git://",
"github:",
"bitbucket:",
"gist:",
"http:",
"https:",
"workspace:",
"npm:",
];
const VENDORED_PATH_PATTERNS = [
/(^|\/)node_modules\//,
/(^|\/)vendor\//,
/(^|\/)__pycache__\//,
/(^|\/)\.venv\//,
/(^|\/)venv\//,
/(^|\/)target\//,
/(^|\/)\.cargo\//,
/(^|\/)dist\//,
/(^|\/)build\//,
];
function normalizeName(name: string, registry: SupportedDepRegistry) {
const normalized = name.trim().toLowerCase();
return registry === "cargo" ? normalized.replaceAll("_", "-") : normalized;
}
export function isVendoredDependencyPath(path: string) {
return VENDORED_PATH_PATTERNS.some((pattern) => pattern.test(path));
}
export function parseDependencyFile(path: string, content: string): DepEntry[] {
if (isVendoredDependencyPath(path)) return [];
const basename = path.split("/").pop()?.toLowerCase() ?? "";
const parser = DEP_FILE_PARSERS[basename];
return parser ? dedupeDeps(parser(content, path)) : [];
}
export function dedupeDeps(entries: DepEntry[]) {
const seen = new Set<string>();
return entries.filter((entry) => {
const key = `${entry.registry}:${entry.name}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function stripInlineComment(line: string) {
return line.replace(/\s+#.*$/, "").trim();
}
function parseRequirementsTxt(content: string, path: string): DepEntry[] {
const entries: DepEntry[] = [];
for (const rawLine of content.split("\n")) {
const line = stripInlineComment(rawLine);
if (!line || line.startsWith("-")) continue;
if (/^(?:git\+|https?:|file:|\.{0,2}\/)/i.test(line)) continue;
if (/\s@\s/.test(line)) continue;
const match = line.match(/^([a-zA-Z0-9_][a-zA-Z0-9._-]*)/);
if (!match) continue;
entries.push({ name: normalizeName(match[1], "pypi"), registry: "pypi", source: path });
}
return entries;
}
function parsePackageJson(content: string, path: string): DepEntry[] {
const entries: DepEntry[] = [];
let pkg: Record<string, unknown>;
try {
pkg = JSON.parse(content) as Record<string, unknown>;
} catch {
return entries;
}
for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) {
const deps = pkg[field];
if (!deps || typeof deps !== "object" || Array.isArray(deps)) continue;
for (const [rawName, rawSpec] of Object.entries(deps as Record<string, unknown>)) {
const spec = typeof rawSpec === "string" ? rawSpec.trim().toLowerCase() : "";
if (NON_REGISTRY_NPM_SPEC_PREFIXES.some((prefix) => spec.startsWith(prefix))) continue;
entries.push({ name: normalizeName(rawName, "npm"), registry: "npm", source: path });
}
}
return entries;
}
function parseCargoToml(content: string, path: string): DepEntry[] {
const entries: DepEntry[] = [];
let inDepSection = false;
for (const rawLine of content.split("\n")) {
const line = stripInlineComment(rawLine);
if (/^\[.*\]$/.test(line)) {
const section = line.replace(/[[\]\s]/g, "").toLowerCase();
inDepSection =
section === "dependencies" ||
section === "dev-dependencies" ||
section === "build-dependencies";
continue;
}
if (!inDepSection || !line) continue;
const match = line.match(/^([a-zA-Z0-9_][a-zA-Z0-9_-]*)\s*=/);
if (!match) continue;
entries.push({ name: normalizeName(match[1], "cargo"), registry: "cargo", source: path });
}
return entries;
}
function parsePyprojectToml(content: string, path: string): DepEntry[] {
const entries: DepEntry[] = [];
let inDepArray = false;
let inPoetryDepTable = false;
for (const rawLine of content.split("\n")) {
const line = stripInlineComment(rawLine);
if (/^\[.*\]$/.test(line)) {
inDepArray = false;
const section = line.replace(/[[\]\s]/g, "").toLowerCase();
inPoetryDepTable =
section === "tool.poetry.dependencies" ||
section === "tool.poetry.dev-dependencies" ||
section === "tool.poetry.group.dev.dependencies";
continue;
}
if (/^dependencies\s*=\s*\[/.test(line)) {
inDepArray = true;
const inline = line.match(/\[\s*(.*)\s*\]/);
if (inline) {
for (const item of extractQuotedStrings(inline[1])) addPyPiDependency(entries, item, path);
inDepArray = false;
}
continue;
}
if (inDepArray) {
if (line === "]") {
inDepArray = false;
continue;
}
const quoted = line.match(/^["']([^"']+)["']/);
if (quoted) addPyPiDependency(entries, quoted[1], path);
continue;
}
if (!inPoetryDepTable || !line) continue;
const match = line.match(/^([a-zA-Z0-9_][a-zA-Z0-9._-]*)\s*=/);
if (!match || match[1].toLowerCase() === "python") continue;
entries.push({ name: normalizeName(match[1], "pypi"), registry: "pypi", source: path });
}
return entries;
}
function addPyPiDependency(entries: DepEntry[], spec: string, path: string) {
if (/\s@\s/.test(spec)) return;
const match = spec.match(/^([a-zA-Z0-9_][a-zA-Z0-9._-]*)/);
if (!match) return;
entries.push({ name: normalizeName(match[1], "pypi"), registry: "pypi", source: path });
}
function extractQuotedStrings(s: string) {
return [...s.matchAll(/["']([^"']+)["']/g)].map((match) => match[1]);
}
export function depRegistryUrl(registry: SupportedDepRegistry, packageName: string) {
const encoded =
registry === "npm" && packageName.startsWith("@")
? `@${encodeURIComponent(packageName.slice(1))}`
: encodeURIComponent(packageName);
if (registry === "pypi") return `https://pypi.org/pypi/${encoded}/json`;
if (registry === "npm") return `https://registry.npmjs.org/${encoded}`;
return `https://crates.io/api/v1/crates/${encoded}`;
}
export function summarizeDepRegistryChecks(params: {
results: DepRegistryResult[];
unresolved: DepRegistryUnresolved[];
checkedAt?: number;
}): DepRegistryAnalysis {
const notFound = params.results.filter((result) => !result.exists);
const notFoundPackages = notFound.map((result) => `${result.name} (${result.registry})`);
const unresolvedPackages = params.unresolved.map(
(result) => `${result.name} (${result.registry})`,
);
const checkedAt = params.checkedAt ?? Date.now();
if (notFoundPackages.length > 0) {
const partial =
unresolvedPackages.length > 0
? ` ${unresolvedPackages.length} package(s) could not be checked and will be retried.`
: "";
return {
status: "suspicious",
results: params.results,
notFoundPackages,
unresolvedPackages,
summary: `${notFoundPackages.length} declared dependency package(s) were not found on their public registry: ${notFoundPackages.join(", ")}.${partial}`,
checkedAt,
};
}
if (unresolvedPackages.length > 0) {
return {
status: "error",
results: params.results,
notFoundPackages: [],
unresolvedPackages,
summary: `${unresolvedPackages.length} dependency package(s) could not be verified due to registry lookup errors. The scan will be retried.`,
checkedAt,
};
}
return {
status: "clean",
results: params.results,
notFoundPackages: [],
unresolvedPackages: [],
summary: `All ${params.results.length} declared dependency package(s) verified as present on their public registries.`,
checkedAt,
};
}
export function buildDepRegistryFinding(analysis: DepRegistryAnalysis): ModerationFinding | null {
if (analysis.status !== "suspicious" || analysis.notFoundPackages.length === 0) return null;
return {
code: REASON_CODES.DEP_NOT_FOUND,
severity: "critical",
file: "Dependency manifests",
line: 1,
message: `${analysis.notFoundPackages.length} package(s) referenced in dependency files do not exist on their public registries: ${analysis.notFoundPackages.join(", ")}`,
evidence:
"An attacker could register these phantom package names and inject malicious install-time code through dependency confusion.",
};
}
export function mergeDepRegistryFinding(params: {
staticScan:
| {
status: ModerationVerdict;
reasonCodes: string[];
findings: ModerationFinding[];
summary: string;
engineVersion: string;
checkedAt: number;
}
| undefined;
analysis: DepRegistryAnalysis;
statusFromCodes: (codes: string[]) => ModerationVerdict;
summarizeCodes: (codes: string[]) => string;
}) {
const base = params.staticScan ?? {
status: "clean" as ModerationVerdict,
reasonCodes: [],
findings: [],
summary: "No suspicious patterns detected.",
engineVersion: MODERATION_ENGINE_VERSION,
checkedAt: params.analysis.checkedAt,
};
const findings = base.findings.filter((finding) => finding.code !== REASON_CODES.DEP_NOT_FOUND);
const depFinding = buildDepRegistryFinding(params.analysis);
if (depFinding) findings.push(depFinding);
const reasonCodes = Array.from(new Set(findings.map((finding) => finding.code))).sort((a, b) =>
a.localeCompare(b),
);
return {
...base,
status: params.statusFromCodes(reasonCodes),
reasonCodes,
findings,
summary: params.summarizeCodes(reasonCodes),
checkedAt: params.analysis.checkedAt,
};
}
-1
View File
@@ -45,7 +45,6 @@ export const REASON_CODES = {
MALICIOUS_INSTALL_PROMPT: "malicious.install_terminal_payload",
KNOWN_BLOCKED_SIGNATURE: "malicious.known_blocked_signature",
STEALTH_BROWSER_ABUSE: "malicious.stealth_browser_abuse",
DEP_NOT_FOUND: "suspicious.dep_not_found_on_registry",
} as const;
const MALICIOUS_CODES = new Set<string>([
-4
View File
@@ -365,10 +365,6 @@ export async function publishVersionForUser(
source: "publish",
});
await ctx.scheduler.runAfter(0, internal.depRegistryScan.checkDependencyRegistries, {
versionId: publishResult.versionId,
});
// Schedule the async "API key required?" analyser; non-fatal on failure
// (UI treats `apiKeyRequired === undefined` as "no badge"). Mirrors the
// `backupSkillForPublishInternal` pattern below: `void runAfter(...).catch(...)`
+489 -1
View File
@@ -1,5 +1,9 @@
/* @vitest-environment node */
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@convex-dev/auth/server", () => ({
getAuthUserId: vi.fn(),
}));
vi.mock("./_generated/api", () => ({
internal: {
@@ -21,6 +25,12 @@ vi.mock("./_generated/api", () => ({
backfillSkillFingerprintsInternal: Symbol("backfillSkillFingerprintsInternal"),
applySkillCapabilityTagsInternal: Symbol("applySkillCapabilityTagsInternal"),
backfillSkillCapabilityTagsInternal: Symbol("backfillSkillCapabilityTagsInternal"),
getDependencyRegistryScanCleanupPageInternal: Symbol(
"getDependencyRegistryScanCleanupPageInternal",
),
applyDependencyRegistryScanCleanupInternal: Symbol(
"applyDependencyRegistryScanCleanupInternal",
),
backfillDigestVersionSummary: Symbol("backfillDigestVersionSummary"),
getEmptySkillCleanupPageInternal: Symbol("getEmptySkillCleanupPageInternal"),
applyEmptySkillCleanupInternal: Symbol("applyEmptySkillCleanupInternal"),
@@ -34,6 +44,12 @@ vi.mock("./_generated/api", () => ({
getVersionByIdInternal: Symbol("skills.getVersionByIdInternal"),
getOwnerSkillActivityInternal: Symbol("skills.getOwnerSkillActivityInternal"),
},
skillCards: {
enqueueForVersionInternal: Symbol("skillCards.enqueueForVersionInternal"),
},
securityScan: {
enqueueSkillVersionScanInternal: Symbol("securityScan.enqueueSkillVersionScanInternal"),
},
users: {
getByIdInternal: Symbol("users.getByIdInternal"),
},
@@ -45,6 +61,9 @@ vi.mock("./lib/skillSummary", () => ({
}));
const {
applyDependencyRegistryScanCleanupInternalHandler,
cleanupDependencyRegistryScanDataHandler,
cleanupDependencyRegistryScanDataInternalHandler,
applySkillCapabilityTagsInternal,
backfillDigestVersionSummary,
backfillLatestVersionSummaryInternal,
@@ -61,11 +80,480 @@ const {
} = await import("./maintenance");
const { internal } = await import("./_generated/api");
const { generateSkillSummary } = await import("./lib/skillSummary");
const { getAuthUserId } = await import("@convex-dev/auth/server");
beforeEach(() => {
vi.mocked(getAuthUserId).mockReset();
vi.mocked(getAuthUserId).mockResolvedValue(null);
});
function makeBlob(text: string) {
return { text: () => Promise.resolve(text) } as unknown as Blob;
}
describe("maintenance dependency registry scan cleanup", () => {
it("rejects non-admin public cleanup before scanning cleanup targets", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:caller" as never);
const runQuery = vi.fn(async () => ({
_id: "users:caller",
role: "user",
deletedAt: undefined,
deactivatedAt: undefined,
}));
const runMutation = vi.fn();
await expect(
cleanupDependencyRegistryScanDataHandler({ runQuery, runMutation } as never, {
batchSize: 25,
}),
).rejects.toThrow("Forbidden");
expect(runQuery).toHaveBeenCalledOnce();
expect(runMutation).not.toHaveBeenCalled();
});
it("allows admin public cleanup to delegate to the internal cleanup runner", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:admin" as never);
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("userId" in args) {
return {
_id: "users:admin",
role: "admin",
deletedAt: undefined,
deactivatedAt: undefined,
};
}
return {
versionItems: [],
skillItems: [],
cacheRowIds: [],
versionCursor: null,
skillCursor: null,
cacheCursor: null,
versionsDone: true,
skillsDone: true,
cacheDone: true,
};
});
const runMutation = vi.fn();
const result = await cleanupDependencyRegistryScanDataHandler(
{ runQuery, runMutation } as never,
{ batchSize: 25 },
);
expect(result).toMatchObject({ ok: true, dryRun: true, isDone: true });
expect(runQuery).toHaveBeenCalledTimes(2);
expect(runMutation).not.toHaveBeenCalled();
});
it("dry-runs by default and does not write", async () => {
const runQuery = vi.fn(async () => ({
versionItems: [
{
id: "skillVersions:legacy",
hasDepRegistryAnalysis: true,
hasDepRegistryScanStatus: true,
hasLegacyStaticScan: true,
},
],
skillItems: [{ id: "skills:legacy", hasLegacyModeration: true }],
cacheRowIds: ["depRegistryCache:1"],
versionCursor: null,
skillCursor: null,
cacheCursor: null,
versionsDone: true,
skillsDone: true,
cacheDone: true,
}));
const runMutation = vi.fn();
const result = await cleanupDependencyRegistryScanDataInternalHandler(
{ runQuery, runMutation } as never,
{ batchSize: 25, maxBatches: 1 },
);
expect(result).toMatchObject({
ok: true,
dryRun: true,
stats: {
versionRowsScanned: 1,
versionRowsMatched: 1,
staticScansMatched: 1,
skillRowsScanned: 1,
skillRowsMatched: 1,
cacheRowsScanned: 1,
versionRowsPatched: 0,
staticScansRewritten: 0,
skillRowsPatched: 0,
cacheRowsDeleted: 0,
},
samples: {
versionIds: ["skillVersions:legacy"],
skillIds: ["skills:legacy"],
cacheRowIds: ["depRegistryCache:1"],
},
isDone: true,
});
expect(runMutation).not.toHaveBeenCalled();
});
it("requires the confirmation token before applying", async () => {
await expect(
cleanupDependencyRegistryScanDataInternalHandler({ runQuery: vi.fn() } as never, {
dryRun: false,
}),
).rejects.toThrow(/delete-dependency-registry-scan-data/);
});
it("applies only matched dependency registry cleanup targets", async () => {
const runQuery = vi.fn(async () => ({
versionItems: [
{
id: "skillVersions:legacy",
hasDepRegistryAnalysis: true,
hasDepRegistryScanStatus: true,
hasLegacyStaticScan: false,
},
{
id: "skillVersions:clean",
hasDepRegistryAnalysis: false,
hasDepRegistryScanStatus: false,
hasLegacyStaticScan: false,
},
],
skillItems: [
{ id: "skills:legacy", hasLegacyModeration: true },
{ id: "skills:clean", hasLegacyModeration: false },
],
cacheRowIds: ["depRegistryCache:1"],
versionCursor: "next-version",
skillCursor: "next-skill",
cacheCursor: null,
versionsDone: false,
skillsDone: false,
cacheDone: true,
}));
const runMutation = vi.fn(async () => ({
versionRowsPatched: 1,
staticScansRewritten: 0,
skillRowsPatched: 1,
cacheRowsDeleted: 1,
}));
const result = await cleanupDependencyRegistryScanDataInternalHandler(
{ runQuery, runMutation } as never,
{
dryRun: false,
confirm: "delete-dependency-registry-scan-data",
batchSize: 25,
maxBatches: 1,
},
);
expect(result).toMatchObject({
dryRun: false,
stats: {
versionRowsScanned: 2,
versionRowsMatched: 1,
skillRowsScanned: 2,
skillRowsMatched: 1,
cacheRowsScanned: 1,
versionRowsPatched: 1,
skillRowsPatched: 1,
cacheRowsDeleted: 1,
},
cursors: { versionCursor: "next-version", skillCursor: "next-skill", cacheCursor: null },
isDone: false,
});
expect(runMutation).toHaveBeenCalledWith(
internal.maintenance.applyDependencyRegistryScanCleanupInternal,
{
confirm: "delete-dependency-registry-scan-data",
versionIds: ["skillVersions:legacy"],
skillIds: ["skills:legacy"],
cacheRowIds: ["depRegistryCache:1"],
},
);
});
it("requires the confirmation token at the internal write boundary", async () => {
const get = vi.fn();
const patch = vi.fn();
const deleteRow = vi.fn();
await expect(
applyDependencyRegistryScanCleanupInternalHandler(
{
db: {
get,
patch,
delete: deleteRow,
},
} as never,
{
versionIds: ["skillVersions:legacy" as never],
skillIds: [],
cacheRowIds: ["depRegistryCache:delete-me" as never],
} as never,
),
).rejects.toThrow(/delete-dependency-registry-scan-data/);
expect(get).not.toHaveBeenCalled();
expect(patch).not.toHaveBeenCalled();
expect(deleteRow).not.toHaveBeenCalled();
});
it("removes only targeted version scan fields and cache rows", async () => {
const skillVersions = new Map<string, Record<string, unknown>>([
[
"skillVersions:legacy",
{
_id: "skillVersions:legacy",
depRegistryAnalysis: { status: "suspicious", missing: [] },
depRegistryScanStatus: "suspicious",
staticScan: {
status: "suspicious",
reasonCodes: ["suspicious.dep_not_found_on_registry", "review.too_short"],
findings: [
{ code: "suspicious.dep_not_found_on_registry", message: "missing package" },
{ code: "review.too_short", message: "too short" },
],
summary: "Detected: missing package",
},
},
],
[
"skillVersions:untouched",
{
_id: "skillVersions:untouched",
depRegistryAnalysis: { status: "clean", missing: [] },
depRegistryScanStatus: "clean",
},
],
]);
const depRegistryCache = new Map<string, Record<string, unknown>>([
["depRegistryCache:delete-me", { _id: "depRegistryCache:delete-me" }],
["depRegistryCache:keep-me", { _id: "depRegistryCache:keep-me" }],
]);
const tableMap: Record<string, Map<string, Record<string, unknown>>> = {
skillVersions,
depRegistryCache,
};
const getTableForId = (id: string) => id.split(":")[0];
const patch = vi.fn(async (id: string, value: Record<string, unknown>) => {
Object.assign(tableMap[getTableForId(id)].get(id) ?? {}, value);
});
const deleteRow = vi.fn(async (id: string) => {
tableMap[getTableForId(id)].delete(id);
});
const runAfter = vi.fn();
const result = await applyDependencyRegistryScanCleanupInternalHandler(
{
db: {
get: vi.fn(async (id: string) => tableMap[getTableForId(id)]?.get(id) ?? null),
patch,
delete: deleteRow,
},
scheduler: { runAfter },
} as never,
{
confirm: "delete-dependency-registry-scan-data",
versionIds: ["skillVersions:legacy" as never],
skillIds: [],
cacheRowIds: ["depRegistryCache:delete-me" as never],
},
);
expect(result).toEqual({
versionRowsPatched: 1,
staticScansRewritten: 1,
skillRowsPatched: 0,
cacheRowsDeleted: 1,
});
expect(patch).toHaveBeenCalledWith("skillVersions:legacy", {
depRegistryAnalysis: undefined,
depRegistryScanStatus: undefined,
staticScan: {
status: "clean",
reasonCodes: ["review.too_short"],
findings: [{ code: "review.too_short", message: "too short" }],
summary: "Review: review.too_short",
},
});
expect(deleteRow).toHaveBeenCalledWith("depRegistryCache:delete-me");
expect(skillVersions.get("skillVersions:untouched")).toMatchObject({
depRegistryAnalysis: { status: "clean", missing: [] },
depRegistryScanStatus: "clean",
});
expect(depRegistryCache.has("depRegistryCache:keep-me")).toBe(true);
expect(runAfter).toHaveBeenCalledTimes(2);
expect(runAfter).toHaveBeenCalledWith(0, internal.skillCards.enqueueForVersionInternal, {
versionId: "skillVersions:legacy",
source: "scan",
});
expect(runAfter).toHaveBeenCalledWith(
0,
internal.securityScan.enqueueSkillVersionScanInternal,
{
versionId: "skillVersions:legacy",
source: "backfill",
waitForVtMs: 0,
},
);
});
it("recomputes a hidden skill when retired dependency registry moderation was the only finding", async () => {
const skill = {
_id: "skills:legacy",
_creationTime: 100,
slug: "legacy",
displayName: "Legacy",
summary: "Legacy dep registry-only finding.",
icon: undefined,
ownerUserId: undefined,
ownerPublisherId: undefined,
canonicalSkillId: undefined,
forkOf: undefined,
latestVersionId: "skillVersions:legacy",
installKind: undefined,
githubHasSkillCard: undefined,
githubCurrentStatus: undefined,
githubScanStatus: undefined,
latestVersionSummary: undefined,
tags: {},
capabilityTags: [],
badges: undefined,
stats: { downloads: 0, stars: 0, installsCurrent: 0, installsAllTime: 0 },
statsDownloads: 0,
statsStars: 0,
statsInstallsCurrent: 0,
statsInstallsAllTime: 0,
softDeletedAt: undefined,
moderationStatus: "hidden",
moderationFlags: ["flagged.suspicious"],
moderationReason: "scanner.aggregate.suspicious",
moderationVerdict: "suspicious",
moderationReasonCodes: ["suspicious.dep_not_found_on_registry"],
moderationEvidence: [
{
code: "suspicious.dep_not_found_on_registry",
severity: "warn",
message: "Dependency was not found on its registry.",
},
],
moderationSummary: "Suspicious: suspicious.dep_not_found_on_registry",
isSuspicious: true,
hiddenAt: 111,
hiddenBy: "users:moderator",
createdAt: 100,
updatedAt: 110,
};
const skillVersions = new Map<string, Record<string, unknown>>([
[
"skillVersions:legacy",
{
_id: "skillVersions:legacy",
skillId: "skills:legacy",
softDeletedAt: undefined,
},
],
]);
const skills = new Map<string, Record<string, unknown>>([["skills:legacy", skill]]);
const skillSearchDigest = new Map<string, Record<string, unknown>>([
["skillSearchDigest:legacy", { _id: "skillSearchDigest:legacy", skillId: "skills:legacy" }],
]);
const globalStats = new Map<string, Record<string, unknown>>([
["globalStats:default", { _id: "globalStats:default", key: "default", activeSkillsCount: 4 }],
]);
const tables: Record<string, Map<string, Record<string, unknown>>> = {
skills,
skillVersions,
skillSearchDigest,
globalStats,
depRegistryCache: new Map(),
};
const getTableForId = (id: string) => id.split(":")[0];
const uniqueFromTable = (tableName: string, filters: Record<string, unknown>) => {
for (const row of tables[tableName]?.values() ?? []) {
const matches = Object.entries(filters).every(([field, value]) => row[field] === value);
if (matches) return row;
}
return null;
};
const patch = vi.fn(async (id: string, value: Record<string, unknown>) => {
Object.assign(tables[getTableForId(id)].get(id) ?? {}, value);
});
const runAfter = vi.fn();
const query = vi.fn((tableName: string) => ({
withIndex: (
_indexName: string,
build: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
) => {
const filters: Record<string, unknown> = {};
build({
eq: (field, value) => {
filters[field] = value;
return {
eq: (nextField: string, nextValue: unknown) => {
filters[nextField] = nextValue;
return filters;
},
};
},
});
return { unique: async () => uniqueFromTable(tableName, filters) };
},
}));
const result = await applyDependencyRegistryScanCleanupInternalHandler(
{
db: {
get: vi.fn(async (id: string) => tables[getTableForId(id)]?.get(id) ?? null),
patch,
delete: vi.fn(),
query,
},
scheduler: { runAfter },
} as never,
{
confirm: "delete-dependency-registry-scan-data",
versionIds: [],
skillIds: ["skills:legacy" as never],
cacheRowIds: [],
},
);
expect(result).toEqual({
versionRowsPatched: 0,
staticScansRewritten: 0,
skillRowsPatched: 1,
cacheRowsDeleted: 0,
});
expect(skills.get("skills:legacy")).toMatchObject({
moderationStatus: "active",
moderationReason: "scanner.aggregate.clean",
moderationVerdict: "clean",
moderationReasonCodes: undefined,
moderationEvidence: undefined,
moderationFlags: undefined,
moderationSummary: "No suspicious patterns detected.",
isSuspicious: false,
hiddenAt: undefined,
hiddenBy: undefined,
});
expect(skillSearchDigest.get("skillSearchDigest:legacy")).toMatchObject({
moderationStatus: "active",
moderationReason: "scanner.aggregate.clean",
isSuspicious: false,
skillId: "skills:legacy",
});
expect(globalStats.get("globalStats:default")).toMatchObject({ activeSkillsCount: 5 });
expect(runAfter).not.toHaveBeenCalled();
});
});
type QueryEq = {
eq: (field: string, value: unknown) => QueryEq;
};
+591
View File
@@ -4,6 +4,12 @@ import type { Doc, Id } from "./_generated/dataModel";
import type { ActionCtx, MutationCtx } from "./_generated/server";
import { action, internalAction, internalMutation, internalQuery } from "./functions";
import { assertRole, requireUserFromAction } from "./lib/access";
import { adjustGlobalPublicSkillsCount, getPublicSkillVisibilityDelta } from "./lib/globalStats";
import {
legacyFlagsFromVerdict,
summarizeReasonCodes,
verdictFromCodes,
} from "./lib/moderationReasonCodes";
import { extractPackageDigestFields, upsertPackageSearchDigest } from "./lib/packageSearchDigest";
import {
derivePersonalPublisherHandle,
@@ -30,6 +36,7 @@ import {
extractValidatedDigestFields,
getFirstSearchToken,
normalizeSkillSearchText,
syncSkillSearchDigestForSkill,
upsertSkillSearchDigest,
} from "./lib/skillSearchDigest";
import { generateSkillSummary } from "./lib/skillSummary";
@@ -42,6 +49,9 @@ const DEFAULT_EMPTY_SKILL_MAX_README_BYTES = 8000;
const DEFAULT_EMPTY_SKILL_NOMINATION_THRESHOLD = 3;
const DEFAULT_CAPABILITY_BACKFILL_DELAY_MS = 500;
const PLATFORM_SKILL_LICENSE = "MIT-0" as const;
const DEP_REGISTRY_CLEANUP_CONFIRMATION = "delete-dependency-registry-scan-data" as const;
const RETIRED_DEP_REGISTRY_REASON_CODE = "suspicious.dep_not_found_on_registry";
const CLEANUP_SAMPLE_LIMIT = 5;
type BackfillStats = {
skillsScanned: number;
@@ -128,6 +138,587 @@ type LegacyPublisherOwnershipForUserRepairResult = Omit<
nextPhase?: LegacyPublisherOwnershipTargetPhase;
};
type SkillVersionStaticScan = NonNullable<Doc<"skillVersions">["staticScan"]>;
type DependencyRegistryCleanupVersionItem = {
id: Id<"skillVersions">;
hasDepRegistryAnalysis: boolean;
hasDepRegistryScanStatus: boolean;
hasLegacyStaticScan: boolean;
};
type DependencyRegistryCleanupSkillItem = {
id: Id<"skills">;
hasLegacyModeration: boolean;
};
type DependencyRegistryCleanupPageResult = {
versionItems: DependencyRegistryCleanupVersionItem[];
skillItems: DependencyRegistryCleanupSkillItem[];
cacheRowIds: Id<"depRegistryCache">[];
versionCursor: string | null;
skillCursor: string | null;
cacheCursor: string | null;
versionsDone: boolean;
skillsDone: boolean;
cacheDone: boolean;
};
type DependencyRegistryCleanupStats = {
versionRowsScanned: number;
versionRowsMatched: number;
staticScansMatched: number;
skillRowsScanned: number;
skillRowsMatched: number;
versionRowsPatched: number;
staticScansRewritten: number;
skillRowsPatched: number;
cacheRowsScanned: number;
cacheRowsDeleted: number;
};
type DependencyRegistryCleanupSamples = {
versionIds: Id<"skillVersions">[];
skillIds: Id<"skills">[];
cacheRowIds: Id<"depRegistryCache">[];
};
type DependencyRegistryCleanupActionArgs = {
dryRun?: boolean;
confirm?: typeof DEP_REGISTRY_CLEANUP_CONFIRMATION;
batchSize?: number;
maxBatches?: number;
versionCursor?: string;
skillCursor?: string;
cacheCursor?: string;
versionsDone?: boolean;
skillsDone?: boolean;
cacheDone?: boolean;
};
type DependencyRegistryCleanupActionResult = {
ok: true;
dryRun: boolean;
stats: DependencyRegistryCleanupStats;
samples: DependencyRegistryCleanupSamples;
cursors: {
versionCursor: string | null;
skillCursor: string | null;
cacheCursor: string | null;
};
done: {
versionsDone: boolean;
skillsDone: boolean;
cacheDone: boolean;
};
isDone: boolean;
};
type DependencyRegistryCleanupApplyArgs = {
confirm: typeof DEP_REGISTRY_CLEANUP_CONFIRMATION;
versionIds: Id<"skillVersions">[];
skillIds: Id<"skills">[];
cacheRowIds: Id<"depRegistryCache">[];
};
function emptyDependencyRegistryCleanupStats(): DependencyRegistryCleanupStats {
return {
versionRowsScanned: 0,
versionRowsMatched: 0,
staticScansMatched: 0,
skillRowsScanned: 0,
skillRowsMatched: 0,
versionRowsPatched: 0,
staticScansRewritten: 0,
skillRowsPatched: 0,
cacheRowsScanned: 0,
cacheRowsDeleted: 0,
};
}
function emptyDependencyRegistryCleanupSamples(): DependencyRegistryCleanupSamples {
return {
versionIds: [],
skillIds: [],
cacheRowIds: [],
};
}
function pushSample<T>(samples: T[], value: T) {
if (samples.length < CLEANUP_SAMPLE_LIMIT) samples.push(value);
}
function stripRetiredDependencyRegistryReasonCodes(codes: string[]) {
return codes.filter((code) => code !== RETIRED_DEP_REGISTRY_REASON_CODE);
}
function isScannerMaliciousReason(reason: string | undefined) {
return Boolean(reason?.startsWith("scanner.") && reason.endsWith(".malicious"));
}
function isScannerSuspiciousReason(reason: string | undefined) {
return Boolean(reason?.startsWith("scanner.") && reason.endsWith(".suspicious"));
}
function isRetainedScannerSuspiciousReason(reason: string | undefined) {
return isScannerSuspiciousReason(reason) && reason !== "scanner.aggregate.suspicious";
}
function verdictFromCodesWithScannerReason(params: {
reasonCodes: string[];
scannerReason?: string;
isMalwareBlocked?: boolean;
}) {
if (params.isMalwareBlocked) return "malicious";
const verdict = verdictFromCodes(params.reasonCodes);
if (verdict !== "clean") return verdict;
return isRetainedScannerSuspiciousReason(params.scannerReason) ? "suspicious" : "clean";
}
function summarizeReasonCodesWithScannerReason(params: {
reasonCodes: string[];
scannerReason?: string;
isMalwareBlocked?: boolean;
}) {
if (params.isMalwareBlocked && params.reasonCodes.length === 0) {
return "Detected: malicious scanner verdict";
}
if (params.reasonCodes.length === 0 && isRetainedScannerSuspiciousReason(params.scannerReason)) {
return `Detected: ${params.scannerReason}`;
}
return summarizeReasonCodes(params.reasonCodes);
}
function hasLegacyDependencyRegistryStaticScan(
staticScan: Doc<"skillVersions">["staticScan"],
): staticScan is SkillVersionStaticScan {
return Boolean(
staticScan &&
(staticScan.reasonCodes.includes(RETIRED_DEP_REGISTRY_REASON_CODE) ||
staticScan.findings.some((finding) => finding.code === RETIRED_DEP_REGISTRY_REASON_CODE)),
);
}
function stripDependencyRegistryStaticScan(staticScan: SkillVersionStaticScan) {
if (!hasLegacyDependencyRegistryStaticScan(staticScan)) return staticScan;
const reasonCodes = stripRetiredDependencyRegistryReasonCodes(staticScan.reasonCodes);
const findings = staticScan.findings.filter(
(finding) => finding.code !== RETIRED_DEP_REGISTRY_REASON_CODE,
);
return {
...staticScan,
status: verdictFromCodes(reasonCodes),
reasonCodes,
findings,
summary: summarizeReasonCodes(reasonCodes),
};
}
function hasLegacyDependencyRegistrySkillModeration(
skill: Pick<Doc<"skills">, "moderationReasonCodes" | "moderationEvidence">,
) {
return (
(skill.moderationReasonCodes ?? []).includes(RETIRED_DEP_REGISTRY_REASON_CODE) ||
(skill.moderationEvidence ?? []).some(
(finding) => finding.code === RETIRED_DEP_REGISTRY_REASON_CODE,
)
);
}
function isScannerModerationReason(reason: string | undefined) {
return reason?.startsWith("scanner.") === true;
}
function moderationReasonFromVerdict(
verdict: Doc<"skills">["moderationVerdict"],
reasonCodes: readonly string[],
) {
if (verdict === "clean" && reasonCodes.some((code) => code.startsWith("review."))) {
return "scanner.llm.review";
}
if (verdict === "malicious") return "scanner.aggregate.malicious";
if (verdict === "suspicious") return "scanner.aggregate.suspicious";
return "scanner.aggregate.clean";
}
function stripDependencyRegistrySkillModeration(
skill: Doc<"skills">,
now: number,
): Partial<Doc<"skills">> | null {
if (!hasLegacyDependencyRegistrySkillModeration(skill)) return null;
const reasonCodes = stripRetiredDependencyRegistryReasonCodes(skill.moderationReasonCodes ?? []);
const moderationEvidence = (skill.moderationEvidence ?? []).filter(
(finding) => finding.code !== RETIRED_DEP_REGISTRY_REASON_CODE,
);
if (skill.moderationStatus === "removed") {
return {
moderationReasonCodes: reasonCodes.length ? reasonCodes : undefined,
moderationEvidence: moderationEvidence.length ? moderationEvidence : undefined,
};
}
const shouldRecomputeScannerState = isScannerModerationReason(skill.moderationReason);
if (!shouldRecomputeScannerState) {
return {
moderationReasonCodes: reasonCodes.length ? reasonCodes : undefined,
moderationEvidence: moderationEvidence.length ? moderationEvidence : undefined,
};
}
const rawIsMalwareBlocked =
skill.moderationVerdict === "malicious" ||
skill.moderationFlags?.includes("blocked.malware") === true ||
isScannerMaliciousReason(skill.moderationReason);
const moderationVerdict = verdictFromCodesWithScannerReason({
reasonCodes,
scannerReason: skill.moderationReason,
isMalwareBlocked: rawIsMalwareBlocked,
});
const moderationFlags: Doc<"skills">["moderationFlags"] = rawIsMalwareBlocked
? ["blocked.malware"]
: moderationVerdict === "clean" && reasonCodes.some((code) => code.startsWith("review."))
? ["flagged.review"]
: legacyFlagsFromVerdict(moderationVerdict);
const moderationReason =
rawIsMalwareBlocked &&
skill.moderationReason?.startsWith("scanner.") &&
skill.moderationReason.endsWith(".malicious")
? skill.moderationReason
: moderationVerdict === "suspicious" &&
reasonCodes.length === 0 &&
isRetainedScannerSuspiciousReason(skill.moderationReason)
? skill.moderationReason
: moderationReasonFromVerdict(moderationVerdict, reasonCodes);
const moderationStatus =
rawIsMalwareBlocked || moderationVerdict === "malicious" ? "hidden" : "active";
const moderationSummary = summarizeReasonCodesWithScannerReason({
reasonCodes,
scannerReason: skill.moderationReason,
isMalwareBlocked: rawIsMalwareBlocked,
});
const patch: Partial<Doc<"skills">> = {
moderationVerdict,
moderationReasonCodes: reasonCodes.length ? reasonCodes : undefined,
moderationEvidence: moderationEvidence.length ? moderationEvidence : undefined,
moderationFlags,
moderationSummary,
moderationReason,
moderationStatus,
isSuspicious: computeIsSuspicious({
moderationFlags,
moderationReason,
}),
};
if (moderationStatus === "hidden") {
patch.hiddenAt = skill.hiddenAt ?? now;
patch.hiddenBy = undefined;
} else if (!skill.softDeletedAt) {
patch.hiddenAt = undefined;
patch.hiddenBy = undefined;
}
return patch;
}
export const getDependencyRegistryScanCleanupPageInternal = internalQuery({
args: {
versionCursor: v.optional(v.string()),
skillCursor: v.optional(v.string()),
cacheCursor: v.optional(v.string()),
batchSize: v.optional(v.number()),
skipVersions: v.optional(v.boolean()),
skipSkills: v.optional(v.boolean()),
skipCache: v.optional(v.boolean()),
},
handler: async (ctx, args): Promise<DependencyRegistryCleanupPageResult> => {
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE);
const versionResult = args.skipVersions
? { page: [], continueCursor: null, isDone: true }
: await ctx.db
.query("skillVersions")
.order("asc")
.paginate({ cursor: args.versionCursor ?? null, numItems: batchSize });
const skillResult = args.skipSkills
? { page: [], continueCursor: null, isDone: true }
: await ctx.db
.query("skills")
.order("asc")
.paginate({ cursor: args.skillCursor ?? null, numItems: batchSize });
const cacheResult = args.skipCache
? { page: [], continueCursor: null, isDone: true }
: await ctx.db
.query("depRegistryCache")
.order("asc")
.paginate({ cursor: args.cacheCursor ?? null, numItems: batchSize });
return {
versionItems: versionResult.page.map((version) => ({
id: version._id,
hasDepRegistryAnalysis: version.depRegistryAnalysis !== undefined,
hasDepRegistryScanStatus: version.depRegistryScanStatus !== undefined,
hasLegacyStaticScan: hasLegacyDependencyRegistryStaticScan(version.staticScan),
})),
skillItems: skillResult.page.map((skill) => ({
id: skill._id,
hasLegacyModeration: hasLegacyDependencyRegistrySkillModeration(skill),
})),
cacheRowIds: cacheResult.page.map((row) => row._id),
versionCursor: versionResult.continueCursor,
skillCursor: skillResult.continueCursor,
cacheCursor: cacheResult.continueCursor,
versionsDone: versionResult.isDone,
skillsDone: skillResult.isDone,
cacheDone: cacheResult.isDone,
};
},
});
export async function applyDependencyRegistryScanCleanupInternalHandler(
ctx: Pick<MutationCtx, "db"> & { scheduler?: Pick<MutationCtx["scheduler"], "runAfter"> },
args: DependencyRegistryCleanupApplyArgs,
) {
if (args.confirm !== DEP_REGISTRY_CLEANUP_CONFIRMATION) {
throw new ConvexError(
'Set confirm to "delete-dependency-registry-scan-data" before applying cleanup.',
);
}
let versionRowsPatched = 0;
let staticScansRewritten = 0;
let skillRowsPatched = 0;
let cacheRowsDeleted = 0;
const now = Date.now();
for (const versionId of args.versionIds) {
const version = await ctx.db.get(versionId);
if (!version) continue;
const patch: Partial<Doc<"skillVersions">> = {};
if (version.depRegistryAnalysis !== undefined) {
patch.depRegistryAnalysis = undefined;
}
if (version.depRegistryScanStatus !== undefined) {
patch.depRegistryScanStatus = undefined;
}
if (version.staticScan) {
const staticScan = stripDependencyRegistryStaticScan(version.staticScan);
if (staticScan !== version.staticScan) {
patch.staticScan = staticScan;
staticScansRewritten++;
}
}
if (Object.keys(patch).length > 0) {
await ctx.db.patch(version._id, patch);
if (patch.staticScan) {
await ctx.scheduler?.runAfter(0, internal.skillCards.enqueueForVersionInternal, {
versionId: version._id,
source: "scan",
});
await ctx.scheduler?.runAfter(0, internal.securityScan.enqueueSkillVersionScanInternal, {
versionId: version._id,
source: "backfill",
waitForVtMs: 0,
});
}
versionRowsPatched++;
}
}
for (const skillId of args.skillIds) {
const skill = await ctx.db.get(skillId);
if (!skill) continue;
const patch = stripDependencyRegistrySkillModeration(skill, now);
if (!patch) continue;
const nextSkill: Doc<"skills"> = { ...skill, ...patch };
const visibilityDelta = getPublicSkillVisibilityDelta(skill, nextSkill);
await ctx.db.patch(skill._id, patch);
await syncSkillSearchDigestForSkill(ctx, nextSkill);
await adjustGlobalPublicSkillsCount(ctx, visibilityDelta, now);
skillRowsPatched++;
}
for (const cacheRowId of args.cacheRowIds) {
const row = await ctx.db.get(cacheRowId);
if (!row) continue;
await ctx.db.delete(row._id);
cacheRowsDeleted++;
}
return { versionRowsPatched, staticScansRewritten, skillRowsPatched, cacheRowsDeleted };
}
export const applyDependencyRegistryScanCleanupInternal = internalMutation({
args: {
confirm: v.literal(DEP_REGISTRY_CLEANUP_CONFIRMATION),
versionIds: v.array(v.id("skillVersions")),
skillIds: v.array(v.id("skills")),
cacheRowIds: v.array(v.id("depRegistryCache")),
},
handler: applyDependencyRegistryScanCleanupInternalHandler,
});
export async function cleanupDependencyRegistryScanDataInternalHandler(
ctx: ActionCtx,
args: DependencyRegistryCleanupActionArgs,
): Promise<DependencyRegistryCleanupActionResult> {
const dryRun = args.dryRun !== false;
if (!dryRun && args.confirm !== DEP_REGISTRY_CLEANUP_CONFIRMATION) {
throw new ConvexError(
'Set confirm to "delete-dependency-registry-scan-data" before applying cleanup.',
);
}
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE);
const maxBatches = clampInt(
args.maxBatches ?? (dryRun ? DEFAULT_MAX_BATCHES : 1),
1,
MAX_MAX_BATCHES,
);
const stats = emptyDependencyRegistryCleanupStats();
const samples = emptyDependencyRegistryCleanupSamples();
let versionsDone = args.versionsDone === true;
let skillsDone = args.skillsDone === true;
let cacheDone = args.cacheDone === true;
let versionCursor = versionsDone ? null : (args.versionCursor ?? null);
let skillCursor = skillsDone ? null : (args.skillCursor ?? null);
let cacheCursor = cacheDone ? null : (args.cacheCursor ?? null);
for (let batchIndex = 0; batchIndex < maxBatches; batchIndex++) {
const page = (await ctx.runQuery(
internal.maintenance.getDependencyRegistryScanCleanupPageInternal,
{
versionCursor: versionCursor ?? undefined,
skillCursor: skillCursor ?? undefined,
cacheCursor: dryRun ? (cacheCursor ?? undefined) : undefined,
batchSize,
skipVersions: versionsDone,
skipSkills: skillsDone,
skipCache: cacheDone,
},
)) as DependencyRegistryCleanupPageResult;
stats.versionRowsScanned += page.versionItems.length;
stats.skillRowsScanned += page.skillItems.length;
stats.cacheRowsScanned += page.cacheRowIds.length;
const matchedVersionIds = page.versionItems
.filter(
(item) =>
item.hasDepRegistryAnalysis || item.hasDepRegistryScanStatus || item.hasLegacyStaticScan,
)
.map((item) => item.id);
const matchedSkillIds = page.skillItems
.filter((item) => item.hasLegacyModeration)
.map((item) => item.id);
const staticScanMatches = page.versionItems.filter((item) => item.hasLegacyStaticScan).length;
stats.versionRowsMatched += matchedVersionIds.length;
stats.staticScansMatched += staticScanMatches;
stats.skillRowsMatched += matchedSkillIds.length;
for (const id of matchedVersionIds) pushSample(samples.versionIds, id);
for (const id of matchedSkillIds) pushSample(samples.skillIds, id);
for (const id of page.cacheRowIds) pushSample(samples.cacheRowIds, id);
if (
!dryRun &&
(matchedVersionIds.length > 0 || matchedSkillIds.length > 0 || page.cacheRowIds.length > 0)
) {
const result = (await ctx.runMutation(
internal.maintenance.applyDependencyRegistryScanCleanupInternal,
{
confirm: DEP_REGISTRY_CLEANUP_CONFIRMATION,
versionIds: matchedVersionIds,
skillIds: matchedSkillIds,
cacheRowIds: page.cacheRowIds,
},
)) as {
versionRowsPatched: number;
staticScansRewritten: number;
skillRowsPatched: number;
cacheRowsDeleted: number;
};
stats.versionRowsPatched += result.versionRowsPatched;
stats.staticScansRewritten += result.staticScansRewritten;
stats.skillRowsPatched += result.skillRowsPatched;
stats.cacheRowsDeleted += result.cacheRowsDeleted;
}
versionCursor = page.versionCursor;
skillCursor = page.skillCursor;
versionsDone = page.versionsDone;
skillsDone = page.skillsDone;
if (dryRun) {
cacheCursor = page.cacheCursor;
cacheDone = page.cacheDone;
} else {
cacheCursor = null;
cacheDone = page.cacheDone;
}
if (versionsDone && skillsDone && cacheDone) break;
}
return {
ok: true,
dryRun,
stats,
samples,
cursors: { versionCursor, skillCursor, cacheCursor },
done: { versionsDone, skillsDone, cacheDone },
isDone: versionsDone && skillsDone && cacheDone,
};
}
export const cleanupDependencyRegistryScanDataInternal = internalAction({
args: {
dryRun: v.optional(v.boolean()),
confirm: v.optional(v.literal(DEP_REGISTRY_CLEANUP_CONFIRMATION)),
batchSize: v.optional(v.number()),
maxBatches: v.optional(v.number()),
versionCursor: v.optional(v.string()),
skillCursor: v.optional(v.string()),
cacheCursor: v.optional(v.string()),
versionsDone: v.optional(v.boolean()),
skillsDone: v.optional(v.boolean()),
cacheDone: v.optional(v.boolean()),
},
handler: cleanupDependencyRegistryScanDataInternalHandler,
});
export async function cleanupDependencyRegistryScanDataHandler(
ctx: ActionCtx,
args: DependencyRegistryCleanupActionArgs,
): Promise<DependencyRegistryCleanupActionResult> {
const { user } = await requireUserFromAction(ctx);
assertRole(user, ["admin"]);
return cleanupDependencyRegistryScanDataInternalHandler(ctx, args);
}
export const cleanupDependencyRegistryScanData: ReturnType<typeof action> = action({
args: {
dryRun: v.optional(v.boolean()),
confirm: v.optional(v.literal(DEP_REGISTRY_CLEANUP_CONFIRMATION)),
batchSize: v.optional(v.number()),
maxBatches: v.optional(v.number()),
versionCursor: v.optional(v.string()),
skillCursor: v.optional(v.string()),
cacheCursor: v.optional(v.string()),
versionsDone: v.optional(v.boolean()),
skillsDone: v.optional(v.boolean()),
cacheDone: v.optional(v.boolean()),
},
handler: cleanupDependencyRegistryScanDataHandler,
});
export const getSkillBackfillPageInternal = internalQuery({
args: {
cursor: v.optional(v.string()),
-8
View File
@@ -199,14 +199,6 @@ describe("skillCards queue", () => {
engineVersion: "test",
checkedAt: 1,
},
depRegistryAnalysis: {
status: "suspicious",
results: [],
notFoundPackages: ["leftpad"],
unresolvedPackages: [],
summary: "raw dependency detail",
checkedAt: 3,
},
vtAnalysis: {
status: "suspicious",
verdict: "suspicious",
-65
View File
@@ -34,7 +34,6 @@ import {
import { getSkillBadgeMap, getSkillBadgeMaps, isSkillHighlighted } from "./lib/badges";
import { scheduleNextBatchIfNeeded } from "./lib/batching";
import { generateChangelogPreview as buildChangelogPreview } from "./lib/changelog";
import { mergeDepRegistryFinding } from "./lib/depRegistryScan";
import { embeddingVisibilityFor } from "./lib/embeddingVisibility";
import {
canHealSkillOwnershipByGitHubProviderAccountId,
@@ -228,31 +227,6 @@ const skillSpectorAnalysisValidator = v.object({
checkedAt: v.number(),
});
const depRegistryStatusValidator = v.union(
v.literal("clean"),
v.literal("suspicious"),
v.literal("error"),
);
const depRegistryValidator = v.union(v.literal("pypi"), v.literal("npm"), v.literal("cargo"));
const depRegistryAnalysisValidator = v.object({
status: depRegistryStatusValidator,
results: v.array(
v.object({
name: v.string(),
registry: depRegistryValidator,
source: v.string(),
exists: v.boolean(),
httpStatus: v.optional(v.number()),
}),
),
notFoundPackages: v.array(v.string()),
unresolvedPackages: v.array(v.string()),
summary: v.string(),
checkedAt: v.number(),
});
function buildStructuredModerationPatch(params: {
staticScan?: Doc<"skillVersions">["staticScan"];
vtAnalysis?: Doc<"skillVersions">["vtAnalysis"];
@@ -3046,15 +3020,6 @@ function compactSecurityVerdictVersion(version: Doc<"skillVersions">) {
},
}
: {}),
...(version.depRegistryAnalysis
? {
depRegistryAnalysis: {
status: version.depRegistryAnalysis.status,
summary: version.depRegistryAnalysis.summary,
checkedAt: version.depRegistryAnalysis.checkedAt,
},
}
: {}),
};
}
@@ -7138,36 +7103,6 @@ export const updateSkillVersionStaticScanInternal = internalMutation({
},
});
export const updateVersionDepRegistryAnalysisInternal = internalMutation({
args: {
versionId: v.id("skillVersions"),
depRegistryAnalysis: depRegistryAnalysisValidator,
},
handler: async (ctx, args) => {
const version = await ctx.db.get(args.versionId);
if (!version) return { ok: true as const, skipped: "missing" as const };
const staticScan = mergeDepRegistryFinding({
staticScan: version.staticScan,
analysis: args.depRegistryAnalysis,
statusFromCodes: verdictFromCodes,
summarizeCodes: summarizeReasonCodes,
});
const versionPatch = {
depRegistryAnalysis: args.depRegistryAnalysis,
depRegistryScanStatus: args.depRegistryAnalysis.status,
staticScan,
};
await ctx.db.patch(version._id, versionPatch);
await ctx.scheduler?.runAfter(0, internal.skillCards.enqueueForVersionInternal, {
versionId: version._id,
source: "scan",
});
return { ok: true as const, status: args.depRegistryAnalysis.status };
},
});
export const scanSkillVersionStaticallyInternal: ReturnType<typeof internalAction> = internalAction(
{
args: {
+3 -1
View File
@@ -440,7 +440,8 @@ Notes:
- `ok` is `true` only when the selected version has a generated Skill Card, is not malware-blocked by moderation, and ClawScan verification is clean.
- Skill identity, publisher identity, and selected version metadata are top-level envelope fields (`slug`, `displayName`, `publisherHandle`, `version`, `resolvedFrom`, `tag`, `createdAt`) so shell automation can read them without unpacking nested wrappers.
- `security` is the top-level ClawScan/security verdict. Automation should key off `ok`, `decision`, `reasons`, and `security.status`.
- `security.signals` contains supporting scanner evidence such as `staticScan`, `virusTotal`, `skillSpector`, and `dependencyRegistry`.
- `security.signals` contains supporting scanner evidence such as `staticScan`, `virusTotal`, and `skillSpector`.
- `security.signals.dependencyRegistry` is retained for v1 response compatibility, but the dependency registry existence scanner is retired and this key is always `null`.
- `provenance` is `server-resolved-github-import` only when ClawHub resolved and stored a GitHub repo/ref/commit/path during publish or import; otherwise it is `unavailable`.
### `POST /api/v1/skills/-/security-verdicts`
@@ -463,6 +464,7 @@ Notes:
- Results are per item; one missing skill or version does not fail the whole response.
- The response is security-only. It does not include Skill Card data, generated card status, artifact file lists, or detailed scanner payloads.
- `security.signals` contains status-level supporting evidence only; use `/scan` or the ClawHub security-audit page for full scanner details.
- `security.signals.dependencyRegistry` is retained for v1 response compatibility, but the dependency registry existence scanner is retired and this key is always `null`.
- Skill Card absence does not affect this endpoint's `ok`, `decision`, or `reasons`; clients should read installed `skill-card.md` locally when they need card content.
- Use `/verify` when you need the single-skill Skill Card verification envelope, `/card` when you need generated card markdown, and `/scan` when you need detailed scanner data.
+1 -1
View File
@@ -1107,7 +1107,7 @@ describe("clawhub e2e", () => {
recommendation: "SAFE",
issueCount: 0,
},
dependencyRegistry: { status: "clean" },
dependencyRegistry: null,
},
},
signature: { status: "unsigned" },