mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d27d624ab | ||
|
|
c5ecc92448 | ||
|
|
d56efee339 | ||
|
|
6732ba0880 | ||
|
|
17b1e8ac85 | ||
|
|
df35fe1746 |
@@ -4,6 +4,7 @@
|
||||
|
||||
### Added
|
||||
- CLI/API: add `set-role` to change user roles (admin only).
|
||||
- Security: quarantine skill publishes with VirusTotal scans + UI (thanks @aleph8, #130).
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
Vendored
+2
@@ -62,6 +62,7 @@ import type * as telemetry from "../telemetry.js";
|
||||
import type * as tokens from "../tokens.js";
|
||||
import type * as uploads from "../uploads.js";
|
||||
import type * as users from "../users.js";
|
||||
import type * as vt from "../vt.js";
|
||||
import type * as webhooks from "../webhooks.js";
|
||||
|
||||
import type {
|
||||
@@ -125,6 +126,7 @@ declare const fullApi: ApiFromModules<{
|
||||
tokens: typeof tokens;
|
||||
uploads: typeof uploads;
|
||||
users: typeof users;
|
||||
vt: typeof vt;
|
||||
webhooks: typeof webhooks;
|
||||
}>;
|
||||
|
||||
|
||||
+9
-6
@@ -1,7 +1,7 @@
|
||||
import { v } from 'convex/values'
|
||||
import { zipSync } from 'fflate'
|
||||
import { api } from './_generated/api'
|
||||
import { httpAction, mutation } from './_generated/server'
|
||||
import { buildDeterministicZip } from './lib/skillZip'
|
||||
import { insertStatEvent } from './skillStatEvents'
|
||||
|
||||
export const downloadZip = httpAction(async (ctx, request) => {
|
||||
@@ -41,16 +41,19 @@ export const downloadZip = httpAction(async (ctx, request) => {
|
||||
return new Response('Version not available', { status: 410 })
|
||||
}
|
||||
|
||||
const files: Record<string, Uint8Array> = {}
|
||||
const entries: Array<{ path: string; bytes: Uint8Array }> = []
|
||||
for (const file of version.files) {
|
||||
const blob = await ctx.storage.get(file.storageId)
|
||||
if (!blob) continue
|
||||
const buffer = new Uint8Array(await blob.arrayBuffer())
|
||||
files[file.path] = buffer
|
||||
entries.push({ path: file.path, bytes: buffer })
|
||||
}
|
||||
|
||||
const zipData = zipSync(files, { level: 6 })
|
||||
const zipArray = Uint8Array.from(zipData)
|
||||
const zipArray = buildDeterministicZip(entries, {
|
||||
ownerId: String(skill.ownerUserId),
|
||||
slug: skill.slug,
|
||||
version: version.version,
|
||||
publishedAt: version.createdAt,
|
||||
})
|
||||
const zipBlob = new Blob([zipArray], { type: 'application/zip' })
|
||||
|
||||
await ctx.runMutation(api.downloads.increment, { skillId: skill._id })
|
||||
|
||||
+1
-1
@@ -28,8 +28,8 @@ import {
|
||||
soulsPostRouterV1Http,
|
||||
starsDeleteRouterV1Http,
|
||||
starsPostRouterV1Http,
|
||||
usersPostRouterV1Http,
|
||||
usersListV1Http,
|
||||
usersPostRouterV1Http,
|
||||
whoamiV1Http,
|
||||
} from './httpApiV1'
|
||||
|
||||
|
||||
@@ -170,6 +170,10 @@ export async function publishVersionForUser(
|
||||
embedding,
|
||||
})) as PublishResult
|
||||
|
||||
await ctx.scheduler.runAfter(0, internal.vt.scanWithVirusTotal, {
|
||||
versionId: publishResult.versionId,
|
||||
})
|
||||
|
||||
const owner = (await ctx.runQuery(internal.users.getByIdInternal, {
|
||||
userId,
|
||||
})) as Doc<'users'> | null
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { zipSync } from 'fflate'
|
||||
|
||||
type ZipEntry = {
|
||||
path: string
|
||||
bytes: Uint8Array
|
||||
}
|
||||
|
||||
export type SkillZipMeta = {
|
||||
ownerId: string
|
||||
slug: string
|
||||
version: string
|
||||
publishedAt: number
|
||||
}
|
||||
|
||||
type ZipInput = Record<string, Uint8Array | [Uint8Array, { mtime?: Date }]>
|
||||
|
||||
const FIXED_ZIP_DATE = new Date('1980-01-01T00:00:00Z')
|
||||
|
||||
export function buildSkillMeta(meta: SkillZipMeta) {
|
||||
return {
|
||||
ownerId: meta.ownerId,
|
||||
slug: meta.slug,
|
||||
version: meta.version,
|
||||
publishedAt: meta.publishedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDeterministicZip(entries: ZipEntry[], meta?: SkillZipMeta) {
|
||||
const sorted = [...entries].sort((a, b) => a.path.localeCompare(b.path))
|
||||
const zipData: ZipInput = {}
|
||||
|
||||
for (const entry of sorted) {
|
||||
zipData[entry.path] = [entry.bytes, { mtime: FIXED_ZIP_DATE }]
|
||||
}
|
||||
|
||||
if (meta) {
|
||||
const metaContent = new TextEncoder().encode(JSON.stringify(buildSkillMeta(meta), null, 2))
|
||||
zipData['_meta.json'] = [metaContent, { mtime: FIXED_ZIP_DATE }]
|
||||
}
|
||||
|
||||
return Uint8Array.from(zipSync(zipData, { level: 6 }))
|
||||
}
|
||||
@@ -157,9 +157,11 @@ const skillVersions = defineTable({
|
||||
createdBy: v.id('users'),
|
||||
createdAt: v.number(),
|
||||
softDeletedAt: v.optional(v.number()),
|
||||
sha256hash: v.optional(v.string()),
|
||||
})
|
||||
.index('by_skill', ['skillId'])
|
||||
.index('by_skill_version', ['skillId', 'version'])
|
||||
.index('by_sha256hash', ['sha256hash'])
|
||||
|
||||
const soulVersions = defineTable({
|
||||
soulId: v.id('souls'),
|
||||
|
||||
+69
-2
@@ -1131,6 +1131,71 @@ export const getVersionByIdInternal = internalQuery({
|
||||
handler: async (ctx, args) => ctx.db.get(args.versionId),
|
||||
})
|
||||
|
||||
export const getSkillByIdInternal = internalQuery({
|
||||
args: { skillId: v.id('skills') },
|
||||
handler: async (ctx, args) => ctx.db.get(args.skillId),
|
||||
})
|
||||
|
||||
export const listVersionsInternal = internalQuery({
|
||||
args: { skillId: v.id('skills') },
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db
|
||||
.query('skillVersions')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', args.skillId))
|
||||
.collect()
|
||||
},
|
||||
})
|
||||
|
||||
export const updateVersionScanResultsInternal = internalMutation({
|
||||
args: {
|
||||
versionId: v.id('skillVersions'),
|
||||
sha256hash: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const version = await ctx.db.get(args.versionId)
|
||||
if (!version) return
|
||||
|
||||
const patch: Partial<Doc<'skillVersions'>> = {}
|
||||
if (args.sha256hash !== undefined) {
|
||||
patch.sha256hash = args.sha256hash
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await ctx.db.patch(args.versionId, patch)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const approveSkillByHashInternal = internalMutation({
|
||||
args: {
|
||||
sha256hash: v.string(),
|
||||
scanner: v.string(),
|
||||
status: v.string(),
|
||||
moderationStatus: v.optional(v.union(v.literal('active'), v.literal('hidden'))),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const version = await ctx.db
|
||||
.query('skillVersions')
|
||||
.withIndex('by_sha256hash', (q) => q.eq('sha256hash', args.sha256hash))
|
||||
.unique()
|
||||
|
||||
if (!version) throw new Error('Version not found for hash')
|
||||
|
||||
// If requested, update the skill's moderation status
|
||||
if (args.moderationStatus) {
|
||||
const skill = await ctx.db.get(version.skillId)
|
||||
if (skill) {
|
||||
await ctx.db.patch(skill._id, {
|
||||
moderationStatus: args.moderationStatus,
|
||||
moderationReason: `scanner.${args.scanner}.${args.status}`,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, skillId: version.skillId, versionId: version._id }
|
||||
},
|
||||
})
|
||||
export const getVersionBySkillAndVersion = query({
|
||||
args: { skillId: v.id('skills'), version: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -1759,7 +1824,8 @@ export const insertVersion = internalMutation({
|
||||
official: undefined,
|
||||
deprecated: undefined,
|
||||
},
|
||||
moderationStatus: 'active',
|
||||
moderationStatus: 'hidden',
|
||||
moderationReason: 'pending.scan',
|
||||
moderationFlags: moderationFlags.length ? moderationFlags : undefined,
|
||||
reportCount: 0,
|
||||
lastReportedAt: undefined,
|
||||
@@ -1826,7 +1892,8 @@ export const insertVersion = internalMutation({
|
||||
tags: nextTags,
|
||||
stats: { ...skill.stats, versions: skill.stats.versions + 1 },
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: skill.moderationStatus ?? 'active',
|
||||
moderationStatus: 'hidden',
|
||||
moderationReason: 'pending.scan',
|
||||
moderationFlags: moderationFlags.length ? moderationFlags : undefined,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
import { v } from 'convex/values'
|
||||
import { internal } from './_generated/api'
|
||||
import { action, internalAction } from './_generated/server'
|
||||
import { buildDeterministicZip } from './lib/skillZip'
|
||||
|
||||
const BENIGN_VERDICTS = new Set(['benign', 'clean'])
|
||||
const MALICIOUS_VERDICTS = new Set(['malicious'])
|
||||
const SUSPICIOUS_VERDICTS = new Set(['suspicious'])
|
||||
|
||||
function normalizeVerdict(value?: string) {
|
||||
return value?.trim().toLowerCase() ?? ''
|
||||
}
|
||||
|
||||
function verdictToStatus(verdict: string) {
|
||||
if (BENIGN_VERDICTS.has(verdict)) return 'clean'
|
||||
if (MALICIOUS_VERDICTS.has(verdict)) return 'malicious'
|
||||
if (SUSPICIOUS_VERDICTS.has(verdict)) return 'suspicious'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
type VTAIResult = {
|
||||
category: string
|
||||
verdict: string
|
||||
analysis?: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
type VTFileResponse = {
|
||||
data: {
|
||||
attributes: {
|
||||
sha256: string
|
||||
crowdsourced_ai_results?: VTAIResult[]
|
||||
last_analysis_stats?: {
|
||||
malicious: number
|
||||
suspicious: number
|
||||
undetected: number
|
||||
harmless: number
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchResults = action({
|
||||
args: {
|
||||
sha256hash: v.optional(v.string()),
|
||||
},
|
||||
handler: async (_ctx, args) => {
|
||||
if (!args.sha256hash) {
|
||||
return { status: 'not_found' }
|
||||
}
|
||||
|
||||
const apiKey = process.env.VT_API_KEY
|
||||
if (!apiKey) {
|
||||
return { status: 'error', message: 'VT_API_KEY not configured' }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://www.virustotal.com/api/v3/files/${args.sha256hash}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-apikey': apiKey,
|
||||
},
|
||||
})
|
||||
|
||||
if (response.status === 404) {
|
||||
return { status: 'not_found' }
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return { status: 'error' }
|
||||
}
|
||||
|
||||
const data = (await response.json()) as VTFileResponse
|
||||
const aiResult = data.data.attributes.crowdsourced_ai_results?.find(
|
||||
(r) => r.category === 'code_insight',
|
||||
)
|
||||
|
||||
const stats = data.data.attributes.last_analysis_stats
|
||||
let status = 'pending'
|
||||
|
||||
if (aiResult?.verdict) {
|
||||
// Prioritize AI Analysis (Code Insight)
|
||||
status = verdictToStatus(normalizeVerdict(aiResult.verdict))
|
||||
} else if (stats) {
|
||||
// Fallback to AV engines
|
||||
if (stats.malicious > 0) {
|
||||
status = 'malicious'
|
||||
} else if (stats.suspicious > 0) {
|
||||
status = 'suspicious'
|
||||
} else if (stats.harmless > 0) {
|
||||
status = 'clean'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
url: `https://www.virustotal.com/gui/file/${args.sha256hash}`,
|
||||
metadata: {
|
||||
aiVerdict: aiResult?.verdict,
|
||||
aiAnalysis: aiResult?.analysis,
|
||||
aiSource: aiResult?.source,
|
||||
stats: stats,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching VT results:', error)
|
||||
return { status: 'error' }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const scanWithVirusTotal = internalAction({
|
||||
args: {
|
||||
versionId: v.id('skillVersions'),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const apiKey = process.env.VT_API_KEY
|
||||
if (!apiKey) {
|
||||
console.log('VT_API_KEY not configured, skipping scan')
|
||||
return
|
||||
}
|
||||
|
||||
// Get the version details and files
|
||||
const version = await ctx.runQuery(internal.skills.getVersionByIdInternal, {
|
||||
versionId: args.versionId,
|
||||
})
|
||||
|
||||
if (!version) {
|
||||
console.error(`Version ${args.versionId} not found for scanning`)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch skill info for _meta.json
|
||||
const skill = await ctx.runQuery(internal.skills.getSkillByIdInternal, {
|
||||
skillId: version.skillId,
|
||||
})
|
||||
if (!skill) {
|
||||
console.error(`Skill ${version.skillId} not found for scanning`)
|
||||
return
|
||||
}
|
||||
|
||||
// Build deterministic ZIP with stable meta (no version history).
|
||||
const entries: Array<{ path: string; bytes: Uint8Array }> = []
|
||||
for (const file of version.files) {
|
||||
const content = await ctx.storage.get(file.storageId)
|
||||
if (content) {
|
||||
const buffer = new Uint8Array(await content.arrayBuffer())
|
||||
entries.push({ path: file.path, bytes: buffer })
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.warn(`No files found for version ${args.versionId}, skipping scan`)
|
||||
return
|
||||
}
|
||||
|
||||
const zipArray = buildDeterministicZip(entries, {
|
||||
ownerId: String(skill.ownerUserId),
|
||||
slug: skill.slug,
|
||||
version: version.version,
|
||||
publishedAt: version.createdAt,
|
||||
})
|
||||
|
||||
// Calculate SHA-256 of the ZIP (this hash includes _meta.json)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', zipArray)
|
||||
const sha256hash = Array.from(new Uint8Array(hashBuffer))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
|
||||
// Update version with hash
|
||||
await ctx.runMutation(internal.skills.updateVersionScanResultsInternal, {
|
||||
versionId: args.versionId,
|
||||
sha256hash,
|
||||
})
|
||||
|
||||
// Check if file already exists in VT and has AI analysis
|
||||
try {
|
||||
const existingFile = await checkExistingFile(apiKey, sha256hash)
|
||||
|
||||
if (existingFile) {
|
||||
const aiResult = existingFile.data.attributes.crowdsourced_ai_results?.find(
|
||||
(r) => r.category === 'code_insight',
|
||||
)
|
||||
|
||||
if (aiResult) {
|
||||
// File exists and has AI analysis - use the verdict
|
||||
const verdict = normalizeVerdict(aiResult.verdict)
|
||||
const status = verdictToStatus(verdict)
|
||||
const isSafe = status === 'clean'
|
||||
|
||||
console.log(
|
||||
`Version ${args.versionId} found in VT with AI analysis. Hash: ${sha256hash}. Verdict: ${verdict}`,
|
||||
)
|
||||
|
||||
if (isSafe) {
|
||||
await ctx.runMutation(internal.skills.approveSkillByHashInternal, {
|
||||
sha256hash,
|
||||
scanner: 'vt',
|
||||
status: 'clean',
|
||||
moderationStatus: 'active',
|
||||
})
|
||||
} else if (status === 'malicious' || status === 'suspicious') {
|
||||
await ctx.runMutation(internal.skills.approveSkillByHashInternal, {
|
||||
sha256hash,
|
||||
scanner: 'vt',
|
||||
status,
|
||||
moderationStatus: 'hidden',
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// File exists but no AI analysis - need to upload for fresh scan
|
||||
console.log(
|
||||
`Version ${args.versionId} found in VT but no AI analysis. Hash: ${sha256hash}. Uploading...`,
|
||||
)
|
||||
} else {
|
||||
console.log(`Version ${args.versionId} not found in VT. Hash: ${sha256hash}. Uploading...`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking existing file in VT:', error)
|
||||
// Continue to upload even if check fails
|
||||
}
|
||||
|
||||
// Upload file to VirusTotal (v3 API)
|
||||
const formData = new FormData()
|
||||
const blob = new Blob([zipArray], { type: 'application/zip' })
|
||||
formData.append('file', blob, 'skill.zip')
|
||||
|
||||
try {
|
||||
const response = await fetch('https://www.virustotal.com/api/v3/files', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-apikey': apiKey,
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
console.error('VirusTotal upload error:', error)
|
||||
return
|
||||
}
|
||||
|
||||
const result = (await response.json()) as { data: { id: string } }
|
||||
console.log(
|
||||
`Successfully uploaded version ${args.versionId} to VT. Hash: ${sha256hash}. Analysis ID: ${result.data.id}`,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to upload to VirusTotal:', error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Check if a file already exists in VirusTotal by hash
|
||||
*/
|
||||
async function checkExistingFile(
|
||||
apiKey: string,
|
||||
sha256hash: string,
|
||||
): Promise<VTFileResponse | null> {
|
||||
const response = await fetch(`https://www.virustotal.com/api/v3/files/${sha256hash}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-apikey': apiKey,
|
||||
},
|
||||
})
|
||||
|
||||
if (response.status === 404) {
|
||||
// File not found in VT
|
||||
return null
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
throw new Error(`VT API error: ${response.status} - ${error}`)
|
||||
}
|
||||
|
||||
return (await response.json()) as VTFileResponse
|
||||
}
|
||||
@@ -116,8 +116,20 @@ describe('cmdBanUser', () => {
|
||||
it('fails fuzzy search with multiple matches when not interactive', async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
items: [
|
||||
{ userId: 'users_1', handle: 'moonshine-100rze', displayName: null, name: null, role: null },
|
||||
{ userId: 'users_2', handle: 'moonshine-100rze2', displayName: null, name: null, role: null },
|
||||
{
|
||||
userId: 'users_1',
|
||||
handle: 'moonshine-100rze',
|
||||
displayName: null,
|
||||
name: null,
|
||||
role: null,
|
||||
},
|
||||
{
|
||||
userId: 'users_2',
|
||||
handle: 'moonshine-100rze2',
|
||||
displayName: null,
|
||||
name: null,
|
||||
role: null,
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
})
|
||||
|
||||
@@ -108,7 +108,9 @@ export async function cmdSetRole(
|
||||
method: 'POST',
|
||||
path: `${ApiRoutes.users}/role`,
|
||||
token,
|
||||
body: resolved.userId ? { userId: resolved.userId, role } : { handle: resolved.handle, role },
|
||||
body: resolved.userId
|
||||
? { userId: resolved.userId, role }
|
||||
: { handle: resolved.handle, role },
|
||||
},
|
||||
ApiV1SetRoleResponseSchema,
|
||||
)
|
||||
|
||||
@@ -12,6 +12,164 @@ import { canManageSkill, isModerator } from '../lib/roles'
|
||||
import { useAuthStatus } from '../lib/useAuthStatus'
|
||||
import { SkillDiffCard } from './SkillDiffCard'
|
||||
|
||||
type ScanResult = {
|
||||
status: string
|
||||
url?: string
|
||||
metadata?: unknown
|
||||
}
|
||||
|
||||
function VirusTotalIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="1em"
|
||||
height="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 89"
|
||||
aria-label="VirusTotal"
|
||||
>
|
||||
<title>VirusTotal</title>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M45.292 44.5 0 89h100V0H0l45.292 44.5zM90 80H22l35.987-35.2L22 9h68v71z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function getScanStatusInfo(status: string) {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'benign':
|
||||
return { label: 'Undetected', className: 'scan-status-clean' }
|
||||
case 'clean':
|
||||
return { label: 'Clean', className: 'scan-status-clean' }
|
||||
case 'malicious':
|
||||
return { label: 'Malicious', className: 'scan-status-malicious' }
|
||||
case 'suspicious':
|
||||
return { label: 'Suspicious', className: 'scan-status-suspicious' }
|
||||
case 'loading':
|
||||
return { label: 'Loading...', className: 'scan-status-pending' }
|
||||
case 'pending':
|
||||
case 'not_found':
|
||||
return { label: 'Pending', className: 'scan-status-pending' }
|
||||
case 'error':
|
||||
case 'failed':
|
||||
return { label: 'Error', className: 'scan-status-error' }
|
||||
default:
|
||||
return { label: status, className: 'scan-status-unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
function useSecurityScan(sha256hash?: string, enabled = true) {
|
||||
const fetchVT = useAction(api.vt.fetchResults)
|
||||
const [result, setResult] = useState<ScanResult | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!sha256hash || !enabled) {
|
||||
setResult(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
|
||||
void fetchVT({ sha256hash })
|
||||
.then((res) => {
|
||||
if (!cancelled) {
|
||||
setResult(res)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setResult({ status: 'error' })
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [sha256hash, enabled, fetchVT])
|
||||
|
||||
return { result, loading }
|
||||
}
|
||||
|
||||
function SecurityScanResults({
|
||||
sha256hash,
|
||||
variant = 'panel',
|
||||
enabled = true,
|
||||
}: {
|
||||
sha256hash?: string
|
||||
variant?: 'panel' | 'badge'
|
||||
enabled?: boolean
|
||||
}) {
|
||||
const { result, loading } = useSecurityScan(sha256hash, enabled)
|
||||
|
||||
if (!sha256hash) return null
|
||||
|
||||
const status = loading ? 'loading' : (result?.status ?? 'pending')
|
||||
const url = result?.url
|
||||
const statusInfo = getScanStatusInfo(status)
|
||||
|
||||
// Use dynamic label if no AI verdict but stats are available
|
||||
let displayLabel = statusInfo.label
|
||||
if (!loading && result?.metadata) {
|
||||
const metadata = result.metadata as {
|
||||
aiVerdict?: string
|
||||
stats?: Record<string, number>
|
||||
}
|
||||
if (!metadata.aiVerdict && metadata.stats) {
|
||||
const stats = metadata.stats
|
||||
const total = Object.values(stats).reduce((acc, val) => acc + (val || 0), 0)
|
||||
displayLabel = `${stats.malicious || 0}/${total} engines`
|
||||
}
|
||||
}
|
||||
|
||||
if (variant === 'badge') {
|
||||
return (
|
||||
<div className="version-scan-badge">
|
||||
<VirusTotalIcon className="version-scan-icon version-scan-icon-vt" />
|
||||
<span className={statusInfo.className}>{displayLabel}</span>
|
||||
{url ? (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="version-scan-link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
↗
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="scan-results-panel">
|
||||
<div className="scan-results-title">Security Scans</div>
|
||||
<div className="scan-results-list">
|
||||
<div className="scan-result-row">
|
||||
<div className="scan-result-scanner">
|
||||
<VirusTotalIcon className="scan-result-icon scan-result-icon-vt" />
|
||||
<span className="scan-result-scanner-name">VirusTotal</span>
|
||||
</div>
|
||||
<div className={`scan-result-status ${statusInfo.className}`}>{displayLabel}</div>
|
||||
{url ? (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className="scan-result-link">
|
||||
View report →
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type SkillDetailPageProps = {
|
||||
slug: string
|
||||
canonicalOwner?: string
|
||||
@@ -89,6 +247,7 @@ export function SkillDetailPage({
|
||||
const [tagName, setTagName] = useState('latest')
|
||||
const [tagVersionId, setTagVersionId] = useState<Id<'skillVersions'> | ''>('')
|
||||
const [activeTab, setActiveTab] = useState<'files' | 'compare' | 'versions'>('files')
|
||||
const [versionScanOpen, setVersionScanOpen] = useState<Record<string, boolean>>({})
|
||||
|
||||
const isLoadingSkill = result === undefined
|
||||
const skill = result?.skill
|
||||
@@ -361,6 +520,7 @@ export function SkillDetailPage({
|
||||
Reports require a reason. Abuse may result in a ban.
|
||||
</div>
|
||||
) : null}
|
||||
<SecurityScanResults sha256hash={latestVersion?.sha256hash} />
|
||||
</div>
|
||||
<div className="skill-hero-cta">
|
||||
<div className="skill-version-pill">
|
||||
@@ -665,6 +825,30 @@ export function SkillDetailPage({
|
||||
<div style={{ color: '#5c554e', whiteSpace: 'pre-wrap' }}>
|
||||
{version.changelog}
|
||||
</div>
|
||||
<div className="version-scan-results">
|
||||
{version.sha256hash ? (
|
||||
versionScanOpen[version._id] ? (
|
||||
<SecurityScanResults
|
||||
sha256hash={version.sha256hash}
|
||||
variant="badge"
|
||||
enabled
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
className="version-scan-toggle"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setVersionScanOpen((prev) => ({
|
||||
...prev,
|
||||
[version._id]: true,
|
||||
}))
|
||||
}
|
||||
>
|
||||
Load scan
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{!nixPlugin ? (
|
||||
<div className="version-actions">
|
||||
|
||||
+133
@@ -2737,3 +2737,136 @@ html.theme-transition::view-transition-new(theme) {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* Security Scan Results */
|
||||
.scan-results-panel {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.scan-results-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-soft);
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.scan-result-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.scan-result-scanner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.scan-result-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.scan-result-icon-vt {
|
||||
color: #0030ff;
|
||||
}
|
||||
|
||||
.scan-result-status {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.scan-status-clean {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.scan-status-malicious {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.scan-status-suspicious {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.scan-status-pending {
|
||||
background: rgba(107, 114, 128, 0.1);
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.scan-status-error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.scan-result-link {
|
||||
font-size: 0.85rem;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.scan-result-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.version-scan-results {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.version-scan-toggle {
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.version-scan-toggle:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.version-scan-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.version-scan-badge .scan-result-status {
|
||||
padding: 1px 6px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.version-scan-icon {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.version-scan-icon-vt {
|
||||
color: #0030ff;
|
||||
}
|
||||
|
||||
.version-scan-link {
|
||||
color: var(--ink-soft);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.version-scan-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user