sync: malware page, security alerts, review diagnostics, skill detail improvements

- Add malware security advisory page and SecurityAlertBanner component
- Add review diagnostics API and reviewed stats page
- Improve skill detail page with better scoring display and metadata
- Update review API endpoints with enhanced filtering and stats
- Add skill file serving improvements and cache enhancements
- Remove legacy curation scripts (moved to internal tooling)
- Update CLI search with score filtering support

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 16:08:12 +02:00
parent 3e22d2e238
commit 16ee9e3beb
33 changed files with 1231 additions and 3404 deletions

View File

@@ -36,7 +36,7 @@ type DB = PostgresJsDatabase<typeof schema>;
* Skills with skill_type=NULL (newly crawled, not yet curated) are included
* so they don't disappear between curate.mjs runs.
*/
const browseReadyFilter = sql`(${skills.isDuplicate} = false OR ${skills.isOwnerClaimed} = true) AND ${skills.isStale} = false`;
const browseReadyFilter = sql`(${skills.isDuplicate} = false OR ${skills.isOwnerClaimed} = true) AND ${skills.isStale} = false AND ${skills.isMalicious} = false`;
/** Minimum repo age gap (in days) to trust repo_created_at over stars for duplicate detection */
const REPO_AGE_THRESHOLD_DAYS = 75;
@@ -483,11 +483,41 @@ export const skillQueries = {
if (minScore > 0) {
conditions.push(sql`${skills.latestAiScore} >= ${minScore}`);
}
return db.select().from(skills)
const results = await db.select().from(skills)
.where(and(...conditions))
.orderBy(order)
.limit(limit)
.offset(offset);
if (results.length === 0) return results.map(s => ({ ...s, reviewOutdated: false as boolean }));
// Batch-fetch latest review hashes for these skills
const ids = results.map(s => s.id);
const reviewRows = await db
.select({
skillId: skillReviews.skillId,
hashAtReview: skillReviews.contentHashAtReview,
})
.from(skillReviews)
.where(and(
inArray(skillReviews.skillId, ids),
sql`${skillReviews.contentHashAtReview} IS NOT NULL AND length(${skillReviews.contentHashAtReview}) > 1`,
))
.orderBy(desc(skillReviews.reviewedAt));
// Keep only the latest review per skill
const reviewHashMap = new Map<string, string>();
for (const r of reviewRows) {
if (!reviewHashMap.has(r.skillId) && r.hashAtReview && r.hashAtReview.length > 1) {
reviewHashMap.set(r.skillId, r.hashAtReview);
}
}
return results.map(s => ({
...s,
reviewOutdated: reviewHashMap.has(s.id)
&& s.contentHash != null && s.contentHash.length > 1
&& reviewHashMap.get(s.id) !== s.contentHash,
}));
},
/**
@@ -760,7 +790,7 @@ export const skillQueries = {
await db
.update(skills)
.set({
downloadCount: sql`${skills.downloadCount} + 1`,
downloadCount: sql`COALESCE(${skills.downloadCount}, 0) + 1`,
lastDownloadedAt: new Date(),
})
.where(eq(skills.id, id));
@@ -817,6 +847,23 @@ export const skillQueries = {
await db.update(skills).set({ isBlocked: false }).where(eq(skills.id, id));
},
/**
* Flag a skill as malicious (contains malware)
*/
flagMalicious: async (db: DB, id: string) => {
await db.update(skills).set({
isMalicious: true,
securityStatus: 'fail' as const,
}).where(eq(skills.id, id));
},
/**
* Unflag a skill as malicious
*/
unflagMalicious: async (db: DB, id: string) => {
await db.update(skills).set({ isMalicious: false }).where(eq(skills.id, id));
},
/**
* Mark all skills from a repo as owner-claimed (exempt from duplicate hiding)
*/
@@ -2528,6 +2575,7 @@ export const skillReviewQueries = {
i18nPriority?: number;
contentHashAtReview?: string;
reviewVersion?: number;
recommendation?: string;
}>
) => {
return db
@@ -2549,6 +2597,7 @@ export const skillReviewQueries = {
i18nPriority: r.i18nPriority ?? 0,
contentHashAtReview: r.contentHashAtReview,
reviewVersion: r.reviewVersion ?? 1,
recommendation: r.recommendation,
}))
)
.returning();
@@ -2693,6 +2742,117 @@ export const skillReviewQueries = {
return result[0]?.count ?? 0;
},
/**
* Get pipeline stats for browse-ready SKILL.md skills (matches site-visible skills)
*/
getPublicPipelineStats: async (db: DB) => {
const result = await db
.select({
reviewStatus: skills.reviewStatus,
count: sql<number>`count(*)::int`,
})
.from(skills)
.where(
and(
eq(skills.isBlocked, false),
eq(skills.sourceFormat, 'skill.md'),
browseReadyFilter,
)
)
.groupBy(skills.reviewStatus);
const stats: Record<string, number> = {};
for (const row of result) {
stats[row.reviewStatus ?? 'unreviewed'] = row.count;
}
return stats;
},
/**
* Get AI score distribution for reviewed skills (matches badge system: 75+, 50-74, <50)
*/
getScoreDistribution: async (db: DB) => {
const result = await db
.select({
range: sql<string>`CASE
WHEN ${skills.latestAiScore} >= 75 THEN 'high'
WHEN ${skills.latestAiScore} >= 50 THEN 'mid'
ELSE 'low'
END`,
count: sql<number>`count(*)::int`,
})
.from(skills)
.where(
and(
eq(skills.isBlocked, false),
eq(skills.sourceFormat, 'skill.md'),
browseReadyFilter,
inArray(skills.reviewStatus, ['ai-reviewed', 'verified']),
)
)
.groupBy(sql`CASE
WHEN ${skills.latestAiScore} >= 75 THEN 'high'
WHEN ${skills.latestAiScore} >= 50 THEN 'mid'
ELSE 'low'
END`);
const dist: Record<string, number> = { high: 0, mid: 0, low: 0 };
for (const row of result) {
dist[row.range] = row.count;
}
return dist;
},
/**
* Get security scan stats (counts by security_status)
*/
getSecurityStats: async (db: DB) => {
const result = await db
.select({
status: skills.securityStatus,
count: sql<number>`count(*)::int`,
})
.from(skills)
.where(
and(
eq(skills.isBlocked, false),
eq(skills.sourceFormat, 'skill.md'),
sql`${skills.securityStatus} IS NOT NULL`,
)
)
.groupBy(skills.securityStatus);
const stats: Record<string, number> = { pass: 0, warning: 0, fail: 0 };
for (const row of result) {
if (row.status) stats[row.status] = row.count;
}
return stats;
},
/**
* Get malware-flagged skills (for public malware listing page)
*/
getMaliciousSkills: async (db: DB, limit = 50, offset = 0) => {
return db
.select()
.from(skills)
.where(and(eq(skills.isMalicious, true), eq(skills.isBlocked, false)))
.orderBy(desc(skills.updatedAt))
.limit(limit)
.offset(offset);
},
/**
* Count malware-flagged skills
*/
countMaliciousSkills: async (db: DB) => {
const result = await db
.select({ count: sql<number>`count(*)::int` })
.from(skills)
.where(and(eq(skills.isMalicious, true), eq(skills.isBlocked, false)));
return result[0]?.count ?? 0;
},
/**
* Get skills pending AI review (auto-scored, security=pass, quality>=threshold)
* Only returns standard SKILL.md format skills (excludes agents.md, cursorrules, etc.)
@@ -2710,6 +2870,10 @@ export const skillReviewQueries = {
reReviewAll?: boolean;
ownerLimit?: number;
currentReviewVersion?: number;
sortBy?: 'quality' | 'stars' | 'downloads';
minAiScore?: number;
maxAiScore?: number;
reviewedBefore?: string;
} = {}
) => {
const {
@@ -2721,6 +2885,10 @@ export const skillReviewQueries = {
reReviewAll = false,
ownerLimit = 0,
currentReviewVersion = 0,
sortBy = 'quality',
minAiScore,
maxAiScore,
reviewedBefore,
} = options;
const conditions = [
@@ -2763,6 +2931,19 @@ export const skillReviewQueries = {
conditions.push(eq(skills.securityStatus, 'pass'));
}
// AI score range filters (for targeted re-review of inflated scores)
if (minAiScore != null) {
conditions.push(gte(skills.latestAiScore, minAiScore));
}
if (maxAiScore != null) {
conditions.push(sql`${skills.latestAiScore} <= ${maxAiScore}`);
}
// Date filter: only include skills reviewed before a given date
if (reviewedBefore) {
conditions.push(sql`${skills.latestReviewDate} < ${reviewedBefore}::timestamp`);
}
const selectFields = {
id: skills.id,
name: skills.name,
@@ -2771,6 +2952,7 @@ export const skillReviewQueries = {
qualityScore: skills.qualityScore,
securityStatus: skills.securityStatus,
githubStars: skills.githubStars,
downloadCount: skills.downloadCount,
skillType: skills.skillType,
reviewStatus: skills.reviewStatus,
contentHash: skills.contentHash,
@@ -2780,11 +2962,21 @@ export const skillReviewQueries = {
branch: skills.branch,
};
// Re-review-all: prioritize already-reviewed skills first, then auto-scored
// Use sql.raw() for the ORDER BY to avoid parameterization issues in db.execute()
const orderBySql = reReviewAll
? sql`CASE WHEN review_status IN ('ai-reviewed', 'needs-re-review') THEN 0 ELSE 1 END, quality_score DESC NULLS LAST, github_stars DESC NULLS LAST`
: sql`quality_score DESC NULLS LAST, github_stars DESC NULLS LAST`;
// Build sort expression based on sortBy option
// Use COALESCE to handle NULL download_count/quality_score (NULL + 1 = NULL means
// skills never downloaded have NULL, not 0). Use sql.raw() for ORDER BY to avoid
// Drizzle parameterizing the fragment (which turns it into a string constant).
const sortExprRaw = sortBy === 'downloads'
? `COALESCE(download_count, 0) DESC, COALESCE(quality_score, 0) DESC`
: sortBy === 'stars'
? `COALESCE(github_stars, 0) DESC, COALESCE(quality_score, 0) DESC`
: `COALESCE(quality_score, 0) DESC, COALESCE(github_stars, 0) DESC`;
// Re-review-all: prioritize already-reviewed skills first, then apply sort
const orderByRaw = reReviewAll
? `CASE WHEN review_status IN ('ai-reviewed', 'needs-re-review') THEN 0 ELSE 1 END, ${sortExprRaw}`
: sortExprRaw;
const orderBySql = sql.raw(orderByRaw);
// Owner-capped batch: limit skills per github_owner for diversity
if (ownerLimit > 0) {
@@ -2794,6 +2986,7 @@ export const skillReviewQueries = {
SELECT id, name, description, raw_content, quality_score, security_status,
github_stars, skill_type, review_status, content_hash,
github_owner, github_repo, skill_path, branch,
download_count, latest_ai_score, latest_review_date,
ROW_NUMBER() OVER (
PARTITION BY github_owner
ORDER BY ${orderBySql}
@@ -2803,7 +2996,8 @@ export const skillReviewQueries = {
)
SELECT id, name, description, raw_content AS "rawContent",
quality_score AS "qualityScore", security_status AS "securityStatus",
github_stars AS "githubStars", skill_type AS "skillType",
github_stars AS "githubStars", download_count AS "downloadCount",
skill_type AS "skillType",
review_status AS "reviewStatus", content_hash AS "contentHash",
github_owner AS "githubOwner", github_repo AS "githubRepo",
skill_path AS "skillPath", branch
@@ -2816,10 +3010,18 @@ export const skillReviewQueries = {
return [...results];
}
// Build order: re-review priority first (if applicable), then quality
// Build order: re-review priority first (if applicable), then sort
// Use COALESCE to handle NULLs (download_count is NULL for never-downloaded skills,
// not 0, because increment uses NULL + 1 = NULL). Without COALESCE, NULLs sort
// first in DESC order, making 50K+ NULL skills appear before high-download skills.
const baseSortClauses = sortBy === 'downloads'
? [sql`COALESCE(${skills.downloadCount}, 0) DESC`, sql`COALESCE(${skills.qualityScore}, 0) DESC`]
: sortBy === 'stars'
? [sql`COALESCE(${skills.githubStars}, 0) DESC`, sql`COALESCE(${skills.qualityScore}, 0) DESC`]
: [sql`COALESCE(${skills.qualityScore}, 0) DESC`, sql`COALESCE(${skills.githubStars}, 0) DESC`];
const orderClauses = reReviewAll
? [sql`CASE WHEN ${skills.reviewStatus} IN ('ai-reviewed', 'needs-re-review') THEN 0 ELSE 1 END`, desc(skills.qualityScore), desc(skills.githubStars)]
: [desc(skills.qualityScore), desc(skills.githubStars)];
? [sql`CASE WHEN ${skills.reviewStatus} IN ('ai-reviewed', 'needs-re-review') THEN 0 ELSE 1 END`, ...baseSortClauses]
: baseSortClauses;
return db
.select(selectFields)

View File

@@ -66,6 +66,7 @@ export const skills = pgTable(
isVerified: boolean('is_verified').default(false),
isFeatured: boolean('is_featured').default(false),
isBlocked: boolean('is_blocked').default(false), // Blocked from re-indexing (owner requested removal)
isMalicious: boolean('is_malicious').default(false), // Flagged as containing malware/malicious code
isDeprecated: boolean('is_deprecated').default(false), // Auto-detected from raw_content (DEPRECATED/ARCHIVED markers)
isStale: boolean('is_stale').default(false), // Skill files no longer accessible on GitHub (confirmed after 3 consecutive 404s)
staleSince: timestamp('stale_since'), // When staleness was first detected
@@ -431,6 +432,7 @@ export const skillReviews = pgTable(
contentHashAtReview: text('content_hash_at_review'), // skill hash at time of review
reviewedAt: timestamp('reviewed_at').defaultNow(),
reviewVersion: integer('review_version').default(1), // criteria version for recalibration
recommendation: text('recommendation'), // 'flag-malicious' | null — reviewer recommendation for admin action
},
(table) => ({
skillIdx: index('idx_reviews_skill').on(table.skillId),