/** * BundleScanner — Security validation and static analysis for .vellum bundles. * * Validates zip bundles before they are opened, returning structured results * with block-level (reject) and warn-level (flag) findings. */ import JSZip from "jszip"; // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- export interface ScanFinding { category: "archive" | "html" | "asset"; code: string; message: string; level: "block" | "warn"; } export interface ScanResult { passed: boolean; findings: ScanFinding[]; } // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const MAX_DECOMPRESSED_SIZE = 25 * 1024 * 1024; // 25 MB const MAX_FILE_COUNT = 50; const MAX_COMPRESSION_RATIO = 100; const OBFUSCATION_LINE_LENGTH = 10_000; // 10 KB single line threshold const BLOCKED_EXTENSIONS = new Set([ ".exe", ".sh", ".command", ".app", ".dylib", ".so", ".scpt", ]); const REQUIRED_MANIFEST_FIELDS = [ "format_version", "name", "created_at", "created_by", "entry", "capabilities", ] as const; // Magic byte signatures for image validation const IMAGE_SIGNATURES: Record = { ".png": [{ bytes: [0x89, 0x50, 0x4e, 0x47] }], // \x89PNG ".jpg": [{ bytes: [0xff, 0xd8, 0xff] }], ".jpeg": [{ bytes: [0xff, 0xd8, 0xff] }], ".gif": [{ bytes: [0x47, 0x49, 0x46, 0x38] }], // GIF8 ".webp": [ { bytes: [0x52, 0x49, 0x46, 0x46] }, // RIFF at offset 0 // bytes 8-11 should be WEBP — checked separately ], }; // --------------------------------------------------------------------------- // Main entry point // --------------------------------------------------------------------------- export async function scanBundle(zipPath: string): Promise { const findings: ScanFinding[] = []; const fileData = await Bun.file(zipPath).arrayBuffer(); let zip: JSZip; try { zip = await JSZip.loadAsync(fileData); } catch { findings.push({ category: "archive", code: "invalid_zip", message: "File is not a valid zip archive", level: "block", }); return { passed: false, findings }; } // Run all scan phases const manifest = await scanArchiveStructure( zip, fileData.byteLength, findings, ); if (manifest) { await scanHtmlEntry(zip, manifest.entry as string, findings); } const entryName = manifest ? (manifest.entry as string | undefined) : undefined; await scanAssets(zip, findings, entryName); const passed = !findings.some((f) => f.level === "block"); return { passed, findings }; } // --------------------------------------------------------------------------- // Phase 1: Archive structure scan // --------------------------------------------------------------------------- async function scanArchiveStructure( zip: JSZip, compressedSize: number, findings: ScanFinding[], ): Promise | null> { const entries = Object.keys(zip.files); const fileEntries = entries.filter((e) => !zip.files[e].dir); // File count check if (fileEntries.length > MAX_FILE_COUNT) { findings.push({ category: "archive", code: "too_many_files", message: `Bundle contains ${fileEntries.length} files (max ${MAX_FILE_COUNT})`, level: "block", }); } // Path traversal & blocked extensions for (const name of entries) { if (name.includes("../") || name.startsWith("/")) { findings.push({ category: "archive", code: "path_traversal", message: `Zip entry contains path traversal: ${name}`, level: "block", }); } const lowerName = name.toLowerCase(); for (const ext of BLOCKED_EXTENSIONS) { if (lowerName.endsWith(ext)) { findings.push({ category: "archive", code: "blocked_file_type", message: `Blocked file type ${ext}: ${name}`, level: "block", }); break; } } } // Compute total decompressed size and compression ratio (bail out early to // avoid decompressing the entire archive if limits are exceeded). let totalDecompressed = 0; for (const name of fileEntries) { const entry = zip.files[name]; const data = await entry.async("uint8array"); totalDecompressed += data.byteLength; if (totalDecompressed > MAX_DECOMPRESSED_SIZE) { break; } if ( compressedSize > 0 && totalDecompressed / compressedSize > MAX_COMPRESSION_RATIO ) { break; } } if (totalDecompressed > MAX_DECOMPRESSED_SIZE) { findings.push({ category: "archive", code: "too_large", message: `Total decompressed size ${( totalDecompressed / 1024 / 1024 ).toFixed(1)} MB exceeds ${MAX_DECOMPRESSED_SIZE / 1024 / 1024} MB limit`, level: "block", }); } if ( compressedSize > 0 && totalDecompressed / compressedSize > MAX_COMPRESSION_RATIO ) { findings.push({ category: "archive", code: "zip_bomb", message: `Compression ratio ${( totalDecompressed / compressedSize ).toFixed(0)}:1 exceeds ${MAX_COMPRESSION_RATIO}:1 limit`, level: "block", }); } // Manifest validation const manifestFile = zip.file("manifest.json"); if (!manifestFile) { findings.push({ category: "archive", code: "manifest_missing", message: "manifest.json is missing from the bundle", level: "block", }); return null; } let manifest: Record; try { const manifestText = await manifestFile.async("text"); manifest = JSON.parse(manifestText) as Record; } catch { findings.push({ category: "archive", code: "manifest_malformed", message: "manifest.json is not valid JSON", level: "block", }); return null; } for (const field of REQUIRED_MANIFEST_FIELDS) { if (!(field in manifest)) { findings.push({ category: "archive", code: "manifest_malformed", message: `manifest.json is missing required field: ${field}`, level: "block", }); } } // Only multi-file (format_version 2) bundles are supported; this gate // covers every scan consumer, including the macOS open-bundle flow. if ("format_version" in manifest && Number(manifest.format_version) !== 2) { findings.push({ category: "archive", code: "unsupported_format", message: `Bundle format_version ${String( manifest.format_version, )} is not supported; only format_version 2 (multi-file) bundles can be opened`, level: "block", }); } // Entry file check const entryName = manifest.entry; if (typeof entryName === "string" && !zip.file(entryName)) { findings.push({ category: "archive", code: "entry_missing", message: `Entry file "${entryName}" specified in manifest is missing`, level: "block", }); } return manifest; } // --------------------------------------------------------------------------- // Phase 2: HTML / JS static analysis // --------------------------------------------------------------------------- async function scanHtmlEntry( zip: JSZip, entryName: string, findings: ScanFinding[], ): Promise { const entryFile = zip.file(entryName); if (!entryFile) { return; } const html = await entryFile.async("text"); // --- Block-level: external resource references --- //