Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 18 additions & 11 deletions scripts/publish-catalog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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`);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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");
Expand All @@ -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`);
}
Expand Down
15 changes: 13 additions & 2 deletions scripts/publish-catalog.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] },
};
}

Expand Down Expand Up @@ -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/);
Expand Down Expand Up @@ -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));
Expand Down
3 changes: 2 additions & 1 deletion scripts/test-zip-utils.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Loading