diff --git a/README.md b/README.md index c08d316..9f62511 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ The no-secret preflight checks the target release is unused and verifies the previous catalog's exact downloaded bytes, detached signatures, and pinned public key. The credentialed build step resolves each private BOM commit and release tag exactly before the signing key is exposed. Archives are fail-closed on unsafe ZIP paths/metadata, -compression bombs, executable extras, missing license metadata, and wrong PE +compression bombs, executable extras, missing source-package license metadata, and wrong PE platforms. ## Pull-request checks diff --git a/scripts/publish-catalog.mjs b/scripts/publish-catalog.mjs index 161928f..598b334 100644 --- a/scripts/publish-catalog.mjs +++ b/scripts/publish-catalog.mjs @@ -46,7 +46,7 @@ export async function verifyPreviousPublication({ catalogPath, envelopePath, sig return catalog; } -const MAX_ENTRIES = 16; +const MAX_ENTRIES = 512; const MAX_ENTRY_BYTES = 64 * 1024 * 1024; const MAX_TOTAL_BYTES = 128 * 1024 * 1024; const MAX_COMPRESSION_RATIO = 100; @@ -95,22 +95,25 @@ function zipCentralDirectory(bytes, provider) { const end = offset + 46 + nameLength + extraLength + commentLength; if (end > bytes.length) fail(`${provider}: ZIP central directory entry is truncated`); const name = bytes.subarray(offset + 46, offset + 46 + nameLength).toString("utf8"); - safeArchivePath(name, `${provider} archive entry`); - const collision = name.normalize("NFKC").toLocaleLowerCase("en-US"); + const isDir = name.endsWith("/"); + safeArchivePath(isDir ? name.slice(0, -1) : name, `${provider} archive entry`); + const collision = (isDir ? name.slice(0, -1) : name).normalize("NFKC").toLocaleLowerCase("en-US"); if (names.has(collision)) fail(`${provider}: ZIP entry names collide case-insensitively`); names.add(collision); const mode = (externalAttributes >>> 16) & 0xffff; if ((generalPurposeFlags & 0x1) !== 0) fail(`${provider}: encrypted ZIP entries are forbidden`); if ((externalAttributes & 0x400) !== 0) fail(`${provider}: ZIP reparse-point entries are forbidden`); - if (name.endsWith("/") || (externalAttributes & 0x10) !== 0 || - (mode !== 0 && (mode & 0xf000) !== 0x8000)) fail(`${provider}: ZIP directories/symlinks/special files are forbidden`); + const dosDirectory = (externalAttributes & 0x10) !== 0; + if (isDir ? (!dosDirectory || compressedSize !== 0 || uncompressedSize !== 0 || + (mode !== 0 && (mode & 0xf000) !== 0x4000)) : (dosDirectory || + (mode !== 0 && (mode & 0xf000) !== 0x8000))) fail(`${provider}: invalid ZIP file type`); if (compressedSize === 0 && uncompressedSize > 0 || compressedSize > 0 && uncompressedSize / compressedSize > MAX_COMPRESSION_RATIO) { fail(`${provider}: ZIP compression ratio is unsafe`); } total += uncompressedSize; if (total > MAX_TOTAL_BYTES) fail(`${provider}: ZIP uncompressed size is too large`); - entries.push({ name, compressedSize, uncompressedSize }); + entries.push({ name, compressedSize, uncompressedSize, isDir }); offset = end; } if (entries.length === 0) fail(`${provider}: ZIP has no entries`); @@ -148,8 +151,9 @@ function requiredManifest(manifest, spec, provider) { !Array.isArray(manifest.data.access) || !Array.isArray(manifest.data.defines) || !Array.isArray(manifest.data.mappings)) { fail(`${provider}: archive manifest does not match the reviewed BOM contract`); } - if (!manifest.targets.some((target) => target?.runtime === "worker" && Array.isArray(target.os) && target.os.includes("windows"))) { - fail(`${provider}: archive manifest has no Windows worker target`); + const runtime = spec.kind === "app" ? "kosmos-host" : "worker"; + if (!manifest.targets.some((target) => target?.runtime === runtime && Array.isArray(target.os) && target.os.includes("windows"))) { + fail(`${provider}: archive manifest has no Windows ${runtime} target`); } return manifest; } @@ -189,9 +193,10 @@ export async function inspectArchive(spec, archivePath, zipUtils, sequence) { let entries; try { entries = zipUtils.readZip(archivePath); } catch (error) { fail(`${spec.id}: invalid ZIP archive: ${error.message}`); } const files = entries.filter((entry) => !entry.isDir); - if (entries.length !== central.length || files.length !== entries.length) fail(`${spec.id}: ZIP directories are forbidden`); + if (entries.length !== central.length) fail(`${spec.id}: ZIP directory views disagree`); for (const entry of central) { const decoded = files.find((candidate) => candidate.name === entry.name); + if (entry.isDir) continue; if (!decoded || decoded.data.length !== entry.uncompressedSize) fail(`${spec.id}: ZIP uncompressed size mismatch`); } const manifestEntry = files.find((entry) => entry.name === "manifest.json"); @@ -200,10 +205,12 @@ export async function inspectArchive(spec, archivePath, zipUtils, sequence) { try { manifest = JSON.parse(manifestEntry.data.toString("utf8")); } catch { fail(`${spec.id}: archive manifest.json is invalid JSON`); } requiredManifest(manifest, spec, spec.build?.provider ?? spec.id); const licenseEntry = files.find((entry) => /^license(?:[._-].*)?$/i.test(path.posix.basename(entry.name))); - if (!licenseEntry && typeof manifest.license !== "string") fail(`${spec.id}: archive license is missing`); + if (spec.kind !== "app" && !licenseEntry && typeof manifest.license !== "string") fail(`${spec.id}: archive license is missing`); const expected = ["manifest.json", spec.entrypoint, spec.icon, ...(licenseEntry ? [licenseEntry.name] : [])].sort(); const actual = files.map((entry) => entry.name).sort(); - if (JSON.stringify(actual) !== JSON.stringify(expected)) fail(`${spec.id}: archive contains unexpected files`); + if (spec.kind === "app" ? (expected.some((name) => !actual.includes(name)) || + actual.some((name) => !expected.includes(name) && !name.startsWith("dist/"))) : + JSON.stringify(actual) !== JSON.stringify(expected)) fail(`${spec.id}: archive contains unexpected files`); if (files.some((entry) => /\.(?:exe|dll|sys|scr|com)$/i.test(entry.name) && entry.name !== spec.entrypoint)) { fail(`${spec.id}: unexpected executable or Windows binary in archive`); } diff --git a/scripts/publish-catalog.test.mjs b/scripts/publish-catalog.test.mjs index 2e5f32b..88329ec 100644 --- a/scripts/publish-catalog.test.mjs +++ b/scripts/publish-catalog.test.mjs @@ -104,7 +104,7 @@ function completeManifest(spec) { return { schema_version: 2, id: spec.manifest_id, name: "Fixture", version: spec.version, kind: spec.kind, engine_api: spec.engine_api, entrypoint: spec.entrypoint, icon: spec.icon, publisher: "kosmos", - permissions: [], targets: [{ runtime: "worker", os: ["windows"] }], data: { access: [], defines: [], mappings: [] }, + permissions: [], targets: [{ runtime: spec.kind === "app" ? "kosmos-host" : "worker", os: ["windows"] }], data: { access: [], defines: [], mappings: [] }, }; } @@ -145,6 +145,17 @@ test("archive policy rejects traversal, collisions, missing license, and extra f const valid = path.join(dir, "valid.kspkg"); await writeArchive(valid, spec); await assert.doesNotReject(() => inspectArchive(spec, valid, { readZip }, 8)); + const app = path.join(dir, "app.kspkg"); + const appSpec = archiveSpec({ kind: "app", entrypoint: "dist/index.html", build: undefined, artifact: { name: "app.kspkg", url: "https://example.test/app.kspkg" } }); + writeZip(app, [ + { name: "dist/", data: Buffer.alloc(0), externalAttributes: 0x10 }, + { name: "dist/assets/", data: Buffer.alloc(0), externalAttributes: 0x10 }, + { name: "dist/index.html", data: Buffer.from("app") }, + { name: "dist/assets/app.js", data: Buffer.from("js") }, + { name: "icon.png", data: Buffer.from("icon") }, + { name: "manifest.json", data: JSON.stringify(completeManifest(appSpec)) }, + ]); + await assert.doesNotReject(() => inspectArchive(appSpec, app, { readZip }, 8)); const extra = path.join(dir, "extra.kspkg"); await writeArchive(extra, spec, [{ name: "payload.exe", data: peFixture() }]); await assert.rejects(() => inspectArchive(spec, extra, { readZip }, 8), /unexpected/); @@ -180,7 +191,7 @@ test("archive policy rejects traversal, collisions, missing license, and extra f const symlink = path.join(dir, "symlink.kspkg"); await writeArchive(symlink, spec); await mutateCentral(symlink, spec.entrypoint, (bytes, offset) => bytes.writeUInt32LE(0xa0000000, offset + 38)); - await assert.rejects(() => inspectArchive(spec, symlink, { readZip }, 8), /symlinks|special/); + await assert.rejects(() => inspectArchive(spec, symlink, { readZip }, 8), /file type/); const encrypted = path.join(dir, "encrypted.kspkg"); await writeArchive(encrypted, spec); await mutateCentral(encrypted, spec.entrypoint, (bytes, offset) => bytes.writeUInt16LE(1, offset + 8)); diff --git a/scripts/test-zip-utils.mjs b/scripts/test-zip-utils.mjs index d979a21..d834b41 100644 --- a/scripts/test-zip-utils.mjs +++ b/scripts/test-zip-utils.mjs @@ -29,7 +29,7 @@ export function writeZip(file, entries) { header.writeUInt32LE(data.length, 22); header.writeUInt16LE(name.length, 26); chunks.push(header, name, data); - central.push({ name, data, crc: crc32(data), offset }); + central.push({ name, data, crc: crc32(data), offset, externalAttributes: entry.externalAttributes ?? 0 }); offset += header.length + name.length + data.length; } const centralStart = offset; @@ -42,6 +42,7 @@ export function writeZip(file, entries) { header.writeUInt32LE(entry.data.length, 20); header.writeUInt32LE(entry.data.length, 24); header.writeUInt16LE(entry.name.length, 28); + header.writeUInt32LE(entry.externalAttributes, 38); header.writeUInt32LE(entry.offset, 42); chunks.push(header, entry.name); offset += header.length + entry.name.length;