Compare commits

...
13 changed files with 744 additions and 12 deletions
+1
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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
View File
@@ -28,8 +28,8 @@ import {
soulsPostRouterV1Http,
starsDeleteRouterV1Http,
starsPostRouterV1Http,
usersPostRouterV1Http,
usersListV1Http,
usersPostRouterV1Http,
whoamiV1Http,
} from './httpApiV1'
+4
View File
@@ -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
+42
View File
@@ -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 }))
}
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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,
)
+184
View File
@@ -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
View File
@@ -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);
}