From 26b3322fb756d41f1f555085614dcee063c11ab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 3 Jul 2026 17:00:20 +0800 Subject: [PATCH] fix: harden skill market normalization --- src/server/__tests__/skill-market.test.ts | 67 +++++++++++++++- .../services/skillMarket/clawhubAdapter.ts | 6 +- .../services/skillMarket/skillhubAdapter.ts | 76 ++++++++++++++----- 3 files changed, 125 insertions(+), 24 deletions(-) diff --git a/src/server/__tests__/skill-market.test.ts b/src/server/__tests__/skill-market.test.ts index 9dddc130..888acf6d 100644 --- a/src/server/__tests__/skill-market.test.ts +++ b/src/server/__tests__/skill-market.test.ts @@ -51,6 +51,12 @@ describe('skill market source normalization', () => { }) }) + it('keeps malicious ClawHub scan responses blocked even with warnings', () => { + expect(normalizeClawHubScan({ status: 'malicious', hasWarnings: true })).toMatchObject({ + trustState: 'blocked', + }) + }) + it('normalizes SkillHub list items as fallback candidates with Chinese summary', () => { const result = normalizeSkillHubList(SKILLHUB_TOP_SKILLS_RESPONSE) @@ -60,11 +66,70 @@ describe('skill market source normalization', () => { slug: 'skill-vetter', summaryZh: 'AI智能体技能安全预审工具。', canonicalUrl: 'https://clawhub.ai/spclaudehome/skill-vetter', - trustState: 'benign', + trustState: 'unknown', requiresApiKey: false, }) }) + it('normalizes verified SkillHub list items as signed', () => { + const result = normalizeSkillHubList({ + code: 0, + data: { + skills: [ + { + slug: 'verified-skill', + name: 'Verified Skill', + upstream_url: 'https://github.com/example/verified-skill', + verified: true, + }, + ], + }, + }) + + expect(result.items[0]).toMatchObject({ + slug: 'verified-skill', + canonicalUrl: 'https://github.com/example/verified-skill', + upstreamUrl: 'https://github.com/example/verified-skill', + trustState: 'signed', + }) + }) + + it('falls back when SkillHub external URLs are invalid', () => { + const list = normalizeSkillHubList({ + code: 0, + data: { + skills: [ + { + slug: 'unsafe/slug', + name: 'Unsafe URL Skill', + upstream_url: 'http://evil.test/unsafe/slug', + }, + ], + }, + }) + + expect(list.items[0]).toMatchObject({ + canonicalUrl: 'https://skillhub.cn/skills/unsafe%2Fslug', + upstreamUrl: 'https://skillhub.cn/skills/unsafe%2Fslug', + }) + + const detail = normalizeSkillHubDetail({ + securityReports: { + keen: { status: 'benign', statusText: 'safe' }, + }, + skill: { + slug: 'unsafe/slug', + displayName: 'Unsafe URL Skill', + sourceUrl: 'https://evil.test/unsafe/slug', + }, + }) + + expect(detail).toMatchObject({ + canonicalUrl: 'https://skillhub.cn/skills/unsafe%2Fslug', + trustState: 'benign', + }) + }) + it('normalizes SkillHub detail security reports', () => { const detail = normalizeSkillHubDetail(SKILLHUB_DETAIL_RESPONSE) diff --git a/src/server/services/skillMarket/clawhubAdapter.ts b/src/server/services/skillMarket/clawhubAdapter.ts index 063011a5..092b752b 100644 --- a/src/server/services/skillMarket/clawhubAdapter.ts +++ b/src/server/services/skillMarket/clawhubAdapter.ts @@ -60,11 +60,11 @@ export function normalizeClawHubScan(payload: ClawHubScanResponse): { if (payload.status === 'clean' && !payload.hasWarnings) { return { trustState: 'clean', trustSummary: scannerSummary, packageSha256: payload.sha256 } } - if (payload.status === 'suspicious' || payload.hasWarnings) { - return { trustState: 'warning', trustSummary: scannerSummary, packageSha256: payload.sha256 } - } if (payload.status === 'malicious' || payload.status === 'blocked') { return { trustState: 'blocked', trustSummary: scannerSummary, packageSha256: payload.sha256 } } + if (payload.status === 'suspicious' || payload.hasWarnings) { + return { trustState: 'warning', trustSummary: scannerSummary, packageSha256: payload.sha256 } + } return { trustState: 'unknown', trustSummary: scannerSummary, packageSha256: payload.sha256 } } diff --git a/src/server/services/skillMarket/skillhubAdapter.ts b/src/server/services/skillMarket/skillhubAdapter.ts index ebddd789..a1a6660d 100644 --- a/src/server/services/skillMarket/skillhubAdapter.ts +++ b/src/server/services/skillMarket/skillhubAdapter.ts @@ -37,10 +37,40 @@ type SkillHubDetailResponse = { } } +const ALLOWED_EXTERNAL_URL_HOSTS = new Set([ + 'clawhub.ai', + 'skillhub.cn', + 'api.skillhub.cn', + 'github.com', + 'raw.githubusercontent.com', +]) + function requiresApiKey(labels?: { requires_api_key?: string }) { return labels?.requires_api_key === 'true' } +function skillHubCanonicalUrl(slug: string) { + return `https://skillhub.cn/skills/${encodeURIComponent(slug)}` +} + +function normalizeExternalUrl(value: string | undefined, slug: string) { + const fallbackUrl = skillHubCanonicalUrl(slug) + if (!value) { + return fallbackUrl + } + + try { + const url = new URL(value) + if (url.protocol === 'https:' && ALLOWED_EXTERNAL_URL_HOSTS.has(url.hostname)) { + return url.toString() + } + } catch { + return fallbackUrl + } + + return fallbackUrl +} + function trustFromReports(reports?: Record): { trustState: SkillMarketTrustState trustSummary?: string @@ -58,25 +88,30 @@ function trustFromReports(reports?: Record item.slug && item.name) - .map((item): SkillMarketItem => ({ - source: 'skillhub', - sourceMode: 'fallback', - slug: item.slug!, - displayName: item.name!, - summary: item.description || item.description_zh || '', - summaryZh: item.description_zh, - owner: item.ownerName, - canonicalUrl: item.upstream_url || `https://skillhub.cn/skills/${item.slug}`, - upstreamUrl: item.upstream_url, - version: item.version, - downloads: item.downloads, - installs: item.installs, - stars: item.stars, - category: item.category, - requiresApiKey: requiresApiKey(item.labels), - trustState: item.verified ? 'signed' : 'benign', - installed: false, - })) + .map((item): SkillMarketItem => { + const slug = item.slug! + const normalizedUrl = normalizeExternalUrl(item.upstream_url, slug) + + return { + source: 'skillhub', + sourceMode: 'fallback', + slug, + displayName: item.name!, + summary: item.description || item.description_zh || '', + summaryZh: item.description_zh, + owner: item.ownerName, + canonicalUrl: normalizedUrl, + upstreamUrl: normalizedUrl, + version: item.version, + downloads: item.downloads, + installs: item.installs, + stars: item.stars, + category: item.category, + requiresApiKey: requiresApiKey(item.labels), + trustState: item.verified ? 'signed' : 'unknown', + installed: false, + } + }) return { items, @@ -90,6 +125,7 @@ export function normalizeSkillHubDetail(payload: SkillHubDetailResponse): SkillM const trust = trustFromReports(payload.securityReports) const skill = payload.skill ?? {} const slug = skill.slug || 'unknown' + const normalizedUrl = normalizeExternalUrl(skill.sourceUrl, slug) return { source: 'skillhub', @@ -99,7 +135,7 @@ export function normalizeSkillHubDetail(payload: SkillHubDetailResponse): SkillM summary: skill.summary || skill.summary_zh || '', summaryZh: skill.summary_zh, owner: payload.owner?.handle || payload.owner?.displayName, - canonicalUrl: skill.sourceUrl || `https://skillhub.cn/skills/${slug}`, + canonicalUrl: normalizedUrl, version: payload.latestVersion?.version, downloads: skill.stats?.downloads, installs: skill.stats?.installs,