From 6ef939aa53619c59a723f1315832f2573cb15e70 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Mon, 14 Sep 2026 14:06:56 +0200 Subject: [PATCH 1/3] feat(catalog): validate and publish GitHub plugin listings Signed-off-by: David Viejo --- .github/workflows/catalog.yml | 151 ++++++++++++++++++++++++++++++++++ .gitignore | 1 + package.json | 8 ++ registry/README.md | 36 ++++++++ registry/catalog.json | 33 ++++++++ registry/my-plugin.json | 4 + scripts/catalog.test.ts | 41 +++++++++ scripts/catalog.ts | 128 ++++++++++++++++++++++++++++ scripts/safe-extract.test.ts | 39 +++++++++ scripts/safe-extract.ts | 53 ++++++++++++ scripts/validate-build.sh | 41 +++++++++ 11 files changed, 535 insertions(+) create mode 100644 .github/workflows/catalog.yml create mode 100644 package.json create mode 100644 registry/README.md create mode 100644 registry/catalog.json create mode 100644 registry/my-plugin.json create mode 100644 scripts/catalog.test.ts create mode 100644 scripts/catalog.ts create mode 100644 scripts/safe-extract.test.ts create mode 100644 scripts/safe-extract.ts create mode 100644 scripts/validate-build.sh diff --git a/.github/workflows/catalog.yml b/.github/workflows/catalog.yml new file mode 100644 index 0000000..8666758 --- /dev/null +++ b/.github/workflows/catalog.yml @@ -0,0 +1,151 @@ +name: Plugin catalog + +on: + pull_request: + paths: + - "registry/**" + - "scripts/catalog*" + - "scripts/safe-extract*" + - "scripts/validate-build.sh" + - ".github/workflows/catalog.yml" + push: + branches: [main] + paths: + - "registry/**" + - "scripts/catalog*" + - "scripts/safe-extract*" + - "scripts/validate-build.sh" + schedule: + - cron: "17 3 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + fetch-depth: 0 + - name: Validate metadata in bounded container + run: | + docker run --rm --read-only --cap-drop=ALL --security-opt=no-new-privileges \ + --pids-limit=128 --memory=512m --cpus=1 --tmpfs /tmp:rw,nosuid,nodev,size=64m \ + --mount type=bind,src="$PWD",dst=/work,readonly --workdir /work \ + oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895 bun test scripts/catalog.test.ts scripts/safe-extract.test.ts + docker run --rm --read-only --cap-drop=ALL --security-opt=no-new-privileges \ + --pids-limit=128 --memory=512m --cpus=1 --tmpfs /tmp:rw,nosuid,nodev,size=64m \ + --mount type=bind,src="$PWD",dst=/work,readonly --workdir /work \ + oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895 bun scripts/catalog.ts --check + - name: Compile plugin sources in isolated containers + run: | + set -euo pipefail + image='oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895' + scratch="$(mktemp -d)" + trap 'rm -rf "$scratch"' EXIT + docker run --rm --read-only --cap-drop=ALL --security-opt=no-new-privileges \ + --pids-limit=128 --memory=512m --cpus=1 --tmpfs /tmp:rw,nosuid,nodev,size=64m \ + --mount type=bind,src="$PWD",dst=/work,readonly --workdir /work \ + "$image" bun scripts/catalog.ts --plan > "$scratch/plan.json" + jq -c '.[]' "$scratch/plan.json" | while IFS= read -r item; do + repo="$(jq -r '.repository | sub("^https://github.com/"; "")' <<< "$item")" + commit="$(jq -r '.commit' <<< "$item")" + project="$scratch/project" + mkdir -p "$project" + curl --fail --location --silent --show-error --max-time 60 --max-filesize 20971520 \ + "https://api.github.com/repos/$repo/tarball/$commit" -o "$scratch/source.tar.gz" + test "$(wc -c < "$scratch/source.tar.gz")" -le 20971520 + docker run --rm --cap-drop=ALL --security-opt=no-new-privileges \ + --pids-limit=128 --memory=512m --cpus=1 --tmpfs /tmp:rw,nosuid,nodev,size=128m \ + --mount type=bind,src="$project",dst=/work \ + --mount type=bind,src="$scratch/source.tar.gz",dst=/source.tar.gz,readonly \ + --mount type=bind,src="$PWD/scripts",dst=/scripts,readonly --workdir /work \ + "$image" bun /scripts/safe-extract.ts /source.tar.gz /work + docker run --rm --cap-drop=ALL --security-opt=no-new-privileges \ + --pids-limit=128 --memory=2g --cpus=2 --tmpfs /tmp:rw,nosuid,nodev,size=128m \ + --mount type=bind,src="$project",dst=/work --workdir /work \ + "$image" bun install --frozen-lockfile --ignore-scripts + docker run --rm --network=none --cap-drop=ALL --security-opt=no-new-privileges \ + --pids-limit=128 --memory=2g --cpus=2 --tmpfs /tmp:rw,nosuid,nodev,size=128m \ + --mount type=bind,src="$project",dst=/work --workdir /work "$image" sh -c ' + entrypoint="$(bun -e "const p=await Bun.file(\"package.json\").json();const e=p.temps?.entrypoint;if(typeof e!==\"string\"||!/^src\\/[a-zA-Z0-9_./-]+\\.tsx?$/.test(e)||e.includes(\"..\"))process.exit(1);console.log(e)")" + bun build "$entrypoint" --target=bun --compile --outfile /tmp/temps-plugin-check + ' + echo "Build checked $repo@$commit (install scripts disabled; compile network disabled)" + rm -rf "$project" "$scratch/source.tar.gz" + done + + refresh: + if: >- + github.event_name != 'pull_request' && + (github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'chore(catalog): refresh')) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Compile plugin sources in isolated containers + run: bash scripts/validate-build.sh + - name: Regenerate catalog in isolated container + run: | + docker run --rm --cap-drop=ALL --security-opt=no-new-privileges \ + --pids-limit=128 --memory=512m --cpus=1 --tmpfs /tmp:rw,nosuid,nodev,size=64m \ + --mount type=bind,src="$PWD/registry",dst=/work/registry \ + --mount type=bind,src="$PWD/.catalog-build-plan.json",dst=/work/.catalog-build-plan.json,readonly \ + --mount type=bind,src="$PWD/scripts",dst=/work/scripts,readonly --workdir /work \ + -e CATALOG_BUILD_VERIFIED=1 \ + oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895 bun scripts/catalog.ts --write + - uses: actions/upload-artifact@v4 + with: + name: verified-plugin-catalog + path: registry/catalog.json + if-no-files-found: error + retention-days: 1 + + publish: + needs: refresh + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: main + persist-credentials: false + - uses: actions/download-artifact@v4 + with: + name: verified-plugin-catalog + path: verified-catalog + - name: Validate artifact shape and copy data only + run: | + test -f verified-catalog/catalog.json + test ! -L verified-catalog/catalog.json + test "$(wc -c < verified-catalog/catalog.json)" -le 5242880 + jq -e ' + .schema_version == 1 and + (.generated_at | type == "string") and + (.plugins | type == "array" and all(.[]; + (.name | type == "string") and + (.commit | test("^[a-f0-9]{40}$")) and + (.repository | startswith("https://github.com/")) and + (.validation.metadata == "passed") and + (.validation.build == "passed") + )) + ' verified-catalog/catalog.json + cp verified-catalog/catalog.json registry/catalog.json + - name: Commit generated data only + env: + GH_TOKEN: ${{ github.token }} + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add registry/catalog.json + if ! git diff --cached --quiet; then + git commit -m 'chore(catalog): refresh public plugin metadata' + git -c http.extraheader="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GH_TOKEN" | base64 -w0)" push origin HEAD:main + fi diff --git a/.gitignore b/.gitignore index f22bd49..cb2d075 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ dist/ # Logs *.log +.catalog-build-plan.json diff --git a/package.json b/package.json new file mode 100644 index 0000000..47d8fe3 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "private": true, + "scripts": { + "catalog:check": "bun scripts/catalog.ts --check", + "catalog:generate": "bun scripts/catalog.ts --write", + "test": "bun test" + } +} diff --git a/registry/README.md b/registry/README.md new file mode 100644 index 0000000..4f0e98f --- /dev/null +++ b/registry/README.md @@ -0,0 +1,36 @@ +# Submit a plugin + +Open a pull request adding `registry/.json`: + +```json +{ + "repo": "your-org/your-plugin", + "categories": ["observability"] +} +``` + +The filename must match `temps.name` in the repository's root `package.json`. +Only public GitHub repositories with a root `package.json` and nonempty root +`README.md` are supported. Monorepo paths are deliberately not supported yet; +the installer must understand paths before listings may specify them. The +generator reads metadata from a pinned commit SHA, not a mutable branch URL. + +Allowed categories: `analytics`, `automation`, `databases`, `developer-tools`, +`observability`, `seo`, `security`, `other`. The first is the primary category. + +The `temps` manifest may supply `title`, `summary`, `description`, `platforms`, +`docsUrl`, `logo`, and `screenshots` (objects with relative `path`, `alt`, and +optional `caption`). Logo and screenshot paths are relative to repository root. +Other fields derive from `package.json` and GitHub repository metadata. + +Pull-request validation checks metadata and README availability, then installs +dependencies with lifecycle scripts disabled and compiles the root manifest's +entrypoint in a capped, offline container. It does **not** execute the plugin or +audit its security. The generated catalog records `validation.build: "passed"` +only when the tested commit matches the catalog commit. Consumers must not +present build validation as a security guarantee. + +The dependency-install phase has network access to download locked packages; +only the subsequent compile phase is offline. Lifecycle scripts are disabled +during installation. This is build validation, not full network isolation or +a malware review. diff --git a/registry/catalog.json b/registry/catalog.json new file mode 100644 index 0000000..0999db7 --- /dev/null +++ b/registry/catalog.json @@ -0,0 +1,33 @@ +{ + "schema_version": 1, + "generated_at": "2026-09-14T11:12:07.580Z", + "plugins": [ + { + "name": "my-plugin", + "title": "My plugin", + "summary": "A hello-world native Temps plugin", + "description": "A hello-world native Temps plugin", + "author": "Your team", + "category": "Development", + "repository": "https://github.com/gotempsh/temps-plugin-template", + "docsUrl": null, + "logoUrl": null, + "screenshots": [], + "latestVersion": "0.1.0", + "platforms": [ + "linux-amd64-gnu", + "linux-arm64-gnu", + "linux-amd64-musl", + "linux-arm64-musl", + "darwin-amd64", + "darwin-arm64" + ], + "commit": "6a8dba40a14061367871bd90220ac17b6cdcbc75", + "readmeUrl": "https://raw.githubusercontent.com/gotempsh/temps-plugin-template/6a8dba40a14061367871bd90220ac17b6cdcbc75/README.md", + "validation": { + "metadata": "passed", + "build": "passed" + } + } + ] +} diff --git a/registry/my-plugin.json b/registry/my-plugin.json new file mode 100644 index 0000000..67cc82d --- /dev/null +++ b/registry/my-plugin.json @@ -0,0 +1,4 @@ +{ + "repo": "gotempsh/temps-plugin-template", + "categories": ["developer-tools"] +} diff --git a/scripts/catalog.test.ts b/scripts/catalog.test.ts new file mode 100644 index 0000000..7199353 --- /dev/null +++ b/scripts/catalog.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import { parseListing, resolvePlugin } from "./catalog"; + +const sha = "a".repeat(40); +const listing = { repo: "gotempsh/temps-plugin-template", categories: ["developer-tools"] }; +const pkg = { name: "@your-scope/my-plugin", version: "0.1.0", description: "A hello-world native Temps plugin", author: "Your team", temps: { name: "my-plugin", title: "My plugin", category: "Development", platforms: ["linux-amd64-gnu"] } }; + +function fixture(packageJson: object = pkg, readme = "# My plugin\n") { + const fetcher = (async (input: RequestInfo | URL) => { + const url = String(input); + const body = url.endsWith("/repos/gotempsh/temps-plugin-template") + ? { full_name: listing.repo, private: false, default_branch: "main", owner: { login: "gotempsh" } } + : url.endsWith("/commits/main") ? { sha } + : url.endsWith("/package.json") ? packageJson : readme; + return new Response(typeof body === "string" ? body : JSON.stringify(body), { status: 200 }); + }) as typeof fetch; + return fetcher; +} + +describe("catalog submissions", () => { + test("accepts minimal exact listing", () => expect(parseListing("my-plugin", listing)).toEqual(listing)); + test("rejects path overrides and traversal", () => { + expect(() => parseListing("my-plugin", { ...listing, path: "subdir" })).toThrow("only repo and categories"); + expect(() => parseListing("my-plugin", { ...listing, repo: "gotempsh/../evil" })).toThrow("invalid GitHub"); + }); + test("resolves real template-shaped manifest at immutable commit", async () => { + const result = await resolvePlugin("my-plugin", listing, fixture()); + expect(result.commit).toBe(sha); + expect(result.readmeUrl).toContain(`/${sha}/README.md`); + expect(result.validation).toEqual({ metadata: "passed", build: "not_run" }); + }); + test("rejects mismatched manifest identity", async () => { + await expect(resolvePlugin("my-plugin", listing, fixture({ ...pkg, temps: { ...pkg.temps, name: "other" } }))).rejects.toThrow("temps.name must match"); + }); + test("rejects absent README", async () => { + await expect(resolvePlugin("my-plugin", listing, fixture(pkg, ""))).rejects.toThrow("README.md is empty"); + }); + test("rejects asset traversal", async () => { + await expect(resolvePlugin("my-plugin", listing, fixture({ ...pkg, temps: { ...pkg.temps, logo: "../secret" } }))).rejects.toThrow("invalid asset path"); + }); +}); diff --git a/scripts/catalog.ts b/scripts/catalog.ts new file mode 100644 index 0000000..3065a1a --- /dev/null +++ b/scripts/catalog.ts @@ -0,0 +1,128 @@ +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +const API = "https://api.github.com"; +const categoryLabels: Record = { analytics: "Analytics", automation: "Integrations", databases: "Data", "developer-tools": "Development", observability: "Observability", seo: "SEO", security: "Security", other: "Other" }; +const categories = new Set(Object.keys(categoryLabels)); +const repoPattern = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,38})\/[a-zA-Z0-9._-]{1,100}$/; +const namePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const shaPattern = /^[a-f0-9]{40}$/; + +export type Listing = { repo: string; categories: string[] }; +export type CatalogPlugin = { + name: string; title: string; summary: string; description: string; author: string; + category: string; repository: string; docsUrl: string | null; logoUrl: string | null; + screenshots: { url: string; alt: string; caption: string }[]; + latestVersion: string; platforms: string[]; commit: string; readmeUrl: string; + validation: { metadata: "passed"; build: "not_run" | "passed" }; +}; + +export function parseListing(filename: string, value: unknown): Listing { + if (!namePattern.test(filename)) throw new Error(`Invalid listing filename: ${filename}`); + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${filename}: expected object`); + const data = value as Record; + if (Object.keys(data).sort().join(",") !== "categories,repo") throw new Error(`${filename}: only repo and categories are allowed`); + if (typeof data.repo !== "string" || !repoPattern.test(data.repo) || data.repo.includes("..")) throw new Error(`${filename}: invalid GitHub owner/repo`); + if (!Array.isArray(data.categories) || data.categories.length === 0 || data.categories.some(c => typeof c !== "string" || !categories.has(c)) || new Set(data.categories).size !== data.categories.length) throw new Error(`${filename}: invalid categories`); + return data as Listing; +} + +async function githubJson(url: string, fetcher: typeof fetch): Promise { + const response = await fetcher(url, { headers: { Accept: "application/vnd.github+json", "User-Agent": "temps-plugin-catalog", ...(process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}) } }); + if (!response.ok) throw new Error(`GitHub request failed (${response.status}): ${url}`); + return response.json(); +} + +async function githubText(repo: string, sha: string, path: string, fetcher: typeof fetch): Promise { + const response = await fetcher(`https://raw.githubusercontent.com/${repo}/${sha}/${path}`); + if (!response.ok) throw new Error(`${repo}@${sha}: missing ${path} (${response.status})`); + const contentLength = Number(response.headers.get("content-length") ?? 0); + if (contentLength > 1024 * 1024) throw new Error(`${repo}@${sha}: ${path} too large`); + const text = await response.text(); + if (text.length > 1024 * 1024) throw new Error(`${repo}@${sha}: ${path} too large`); + return text; +} + +function requiredString(value: unknown, context: string): string { + if (typeof value !== "string" || !value.trim()) throw new Error(`${context}: missing nonempty string`); + return value.trim(); +} + +export async function resolvePlugin(name: string, listing: Listing, fetcher: typeof fetch = fetch): Promise { + const repo = await githubJson(`${API}/repos/${listing.repo}`, fetcher); + if (repo.private || repo.full_name?.toLowerCase() !== listing.repo.toLowerCase()) throw new Error(`${listing.repo}: repository is private or renamed`); + const branch = requiredString(repo.default_branch, `${listing.repo} default branch`); + const commit = await githubJson(`${API}/repos/${listing.repo}/commits/${encodeURIComponent(branch)}`, fetcher); + const sha = requiredString(commit.sha, `${listing.repo} commit`); + if (!shaPattern.test(sha)) throw new Error(`${listing.repo}: invalid commit SHA`); + const pkg = JSON.parse(await githubText(listing.repo, sha, "package.json", fetcher)); + const manifest = pkg?.temps; + if (!manifest || typeof manifest !== "object") throw new Error(`${listing.repo}: package.json missing temps manifest`); + if (requiredString(manifest.name, `${listing.repo} temps.name`) !== name) throw new Error(`${listing.repo}: temps.name must match ${name}`); + const readme = await githubText(listing.repo, sha, "README.md", fetcher); + if (!readme.trim()) throw new Error(`${listing.repo}: README.md is empty`); + const title = requiredString(manifest.title ?? pkg.displayName ?? name, `${listing.repo} title`); + const summary = requiredString(manifest.summary ?? pkg.description, `${listing.repo} summary`); + const description = requiredString(manifest.description ?? pkg.description, `${listing.repo} description`); + const author = typeof pkg.author === "string" ? pkg.author : pkg.author?.name; + const version = requiredString(pkg.version, `${listing.repo} version`); + const platforms = Array.isArray(manifest.platforms) ? manifest.platforms : []; + if (platforms.some((p: unknown) => typeof p !== "string" || !/^[a-z0-9_-]+$/.test(p))) throw new Error(`${listing.repo}: invalid platforms`); + const rawBase = `https://raw.githubusercontent.com/${listing.repo}/${sha}`; + const asset = (path: unknown): string | null => { + if (path == null) return null; + if (typeof path !== "string" || !/^(?!\/)(?!.*(?:^|\/)\.\.?\/)[a-zA-Z0-9_./-]+$/.test(path)) throw new Error(`${listing.repo}: invalid asset path`); + return `${rawBase}/${path}`; + }; + const shots = manifest.screenshots ?? []; + if (!Array.isArray(shots) || shots.length > 8) throw new Error(`${listing.repo}: invalid screenshots`); + return { + name, title, summary, description, author: requiredString(author ?? repo.owner?.login, `${listing.repo} author`), + category: categoryLabels[listing.categories[0]], repository: `https://github.com/${listing.repo}`, + docsUrl: typeof manifest.docsUrl === "string" && /^https:\/\//.test(manifest.docsUrl) ? manifest.docsUrl : null, + logoUrl: asset(manifest.logo), screenshots: shots.map((shot: any) => { + const url = asset(shot?.path); + if (!url) throw new Error(`${listing.repo}: screenshot needs path`); + return { url, alt: requiredString(shot.alt, `${listing.repo} screenshot alt`), caption: shot.caption ?? "" }; + }), + latestVersion: version, platforms, commit: sha, readmeUrl: `${rawBase}/README.md`, + validation: { metadata: "passed", build: "not_run" }, + }; +} + +export async function generate(root: string, fetcher: typeof fetch = fetch) { + const registry = join(root, "registry"); + const names = (await readdir(registry)).filter(name => name.endsWith(".json") && name !== "catalog.json").sort(); + const plugins: CatalogPlugin[] = []; + const repos = new Set(); + for (const filename of names) { + const name = filename.slice(0, -5); + const listing = parseListing(name, JSON.parse(await readFile(join(registry, filename), "utf8"))); + if (repos.has(listing.repo.toLowerCase())) throw new Error(`${filename}: duplicate repository`); + repos.add(listing.repo.toLowerCase()); + plugins.push(await resolvePlugin(name, listing, fetcher)); + } + return { schema_version: 1, generated_at: new Date().toISOString(), plugins }; +} + +if (import.meta.main) { + const root = join(import.meta.dir, ".."); + const mode = process.argv[2]; + if (mode !== "--check" && mode !== "--write" && mode !== "--plan") throw new Error("Usage: bun scripts/catalog.ts --check|--write|--plan"); + const catalog = await generate(root); + if (mode === "--plan") { + console.log(JSON.stringify(catalog.plugins.map(plugin => ({ repository: plugin.repository, commit: plugin.commit })))); + process.exit(0); + } + if (mode === "--write") { + if (process.env.CATALOG_BUILD_VERIFIED === "1") { + const plan = JSON.parse(await readFile(join(root, ".catalog-build-plan.json"), "utf8")); + if (!Array.isArray(plan) || JSON.stringify(plan) !== JSON.stringify(catalog.plugins.map(plugin => ({ repository: plugin.repository, commit: plugin.commit })))) { + throw new Error("Build plan does not match resolved catalog commits"); + } + for (const plugin of catalog.plugins) plugin.validation.build = "passed"; + } + await writeFile(join(root, "registry/catalog.json"), `${JSON.stringify(catalog, null, 2)}\n`); + } + console.log(`Validated ${catalog.plugins.length} plugin listing(s)`); +} diff --git a/scripts/safe-extract.test.ts b/scripts/safe-extract.test.ts new file mode 100644 index 0000000..0709f6c --- /dev/null +++ b/scripts/safe-extract.test.ts @@ -0,0 +1,39 @@ +import { test, expect } from "bun:test"; +import { gzipSync } from "node:zlib"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { extract } from "./safe-extract"; + +function entry(name: string, content: string, type = "0") { + const bytes = Buffer.from(content); + const header = Buffer.alloc(512); + header.write(name, 0, 100, "utf8"); + header.write(bytes.length.toString(8).padStart(11, "0") + "\0", 124, 12, "ascii"); + header.write(type, 156, 1, "ascii"); + return Buffer.concat([header, bytes, Buffer.alloc((512 - bytes.length % 512) % 512)]); +} + +async function run(entries: Buffer[]) { + const base = await mkdtemp(join(tmpdir(), "temps-catalog-test-")); + const archive = join(base, "source.tar.gz"); + const output = join(base, "output"); + await writeFile(archive, gzipSync(Buffer.concat([...entries, Buffer.alloc(1024)]))); + return { archive, output }; +} + +test("extracts only root regular files", async () => { + const { archive, output } = await run([entry("source/package.json", "{}"), entry("source/bun.lock", "lock")]); + await extract(archive, output); + expect(await readFile(join(output, "package.json"), "utf8")).toBe("{}"); +}); + +test("rejects traversal", async () => { + const { archive, output } = await run([entry("source/../escape", "no")]); + await expect(extract(archive, output)).rejects.toThrow("Unsafe archive path"); +}); + +test("rejects symlinks", async () => { + const { archive, output } = await run([entry("source/link", "", "2")]); + await expect(extract(archive, output)).rejects.toThrow("Unsafe tar entry type"); +}); diff --git a/scripts/safe-extract.ts b/scripts/safe-extract.ts new file mode 100644 index 0000000..0fdf9a0 --- /dev/null +++ b/scripts/safe-extract.ts @@ -0,0 +1,53 @@ +import { gunzipSync } from "node:zlib"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join, posix } from "node:path"; + +const MAX_ARCHIVE = 20 * 1024 * 1024; +const MAX_EXPANDED = 100 * 1024 * 1024; +const MAX_ENTRIES = 10_000; + +export async function extract(archivePath: string, destination: string) { + const compressed = await Bun.file(archivePath).arrayBuffer(); + if (compressed.byteLength > MAX_ARCHIVE) throw new Error("Archive exceeds 20 MiB compressed limit"); + const tar = gunzipSync(Buffer.from(compressed), { maxOutputLength: MAX_EXPANDED }); + let offset = 0; + let count = 0; + const seen = new Set(); + while (offset + 512 <= tar.length) { + const header = tar.subarray(offset, offset + 512); + if (header.every(byte => byte === 0)) break; + if (++count > MAX_ENTRIES) throw new Error("Archive has too many entries"); + const string = (start: number, length: number) => header.subarray(start, start + length).toString("utf8").split("\0", 1)[0]; + const rawName = [string(345, 155), string(0, 100)].filter(Boolean).join("/"); + const sizeText = string(124, 12).trim(); + if (!/^[0-7]*$/.test(sizeText)) throw new Error("Invalid tar size"); + const size = parseInt(sizeText || "0", 8); + const next = offset + 512 + Math.ceil(size / 512) * 512; + if (!Number.isSafeInteger(size) || next > tar.length) throw new Error("Truncated or oversized tar entry"); + const type = string(156, 1); + if (type === "g" || type === "x") { + // PAX metadata is deliberately ignored; it cannot rewrite parsed paths. + offset = next; + continue; + } + if (type !== "0" && type !== "" && type !== "5") throw new Error(`Unsafe tar entry type: ${type}`); + if (rawName.startsWith("/") || rawName.includes("\\")) throw new Error("Unsafe archive path"); + const parts = rawName.replace(/\/$/, "").split("/"); + if (parts.some(part => !part || part === "." || part === "..")) throw new Error("Unsafe archive path"); + const relative = parts.slice(1).join("/"); + if (relative) { + if (posix.normalize(relative) !== relative || seen.has(relative)) throw new Error("Duplicate or unsafe archive path"); + seen.add(relative); + const target = join(destination, relative); + if (type === "5") await mkdir(target, { recursive: true }); + else { + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, tar.subarray(offset + 512, offset + 512 + size), { flag: "wx", mode: 0o600 }); + } + } + offset = next; + } + if (!seen.has("package.json") || !seen.has("bun.lock")) throw new Error("Archive requires root package.json and bun.lock"); +} + +if (import.meta.main) await extract(process.argv[2], process.argv[3]); diff --git a/scripts/validate-build.sh b/scripts/validate-build.sh new file mode 100644 index 0000000..5ded811 --- /dev/null +++ b/scripts/validate-build.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +image='oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895' +scratch="$(mktemp -d)" +trap 'rm -rf "$scratch"' EXIT + +docker run --rm --read-only --cap-drop=ALL --security-opt=no-new-privileges \ + --pids-limit=128 --memory=512m --cpus=1 --tmpfs /tmp:rw,nosuid,nodev,size=64m \ + --mount type=bind,src="$PWD",dst=/work,readonly --workdir /work \ + "$image" bun scripts/catalog.ts --plan > "$scratch/plan.json" + +jq -c '.[]' "$scratch/plan.json" | while IFS= read -r item; do + repo="$(jq -r '.repository | sub("^https://github.com/"; "")' <<< "$item")" + commit="$(jq -r '.commit' <<< "$item")" + project="$scratch/project" + mkdir -p "$project" + curl --fail --location --silent --show-error --max-time 60 \ + --max-filesize 20971520 "https://api.github.com/repos/$repo/tarball/$commit" -o "$scratch/source.tar.gz" + if (( $(wc -c < "$scratch/source.tar.gz") > 20971520 )); then + echo 'Archive exceeds 20 MiB compressed limit' >&2 + exit 1 + fi + docker run --rm --cap-drop=ALL --security-opt=no-new-privileges \ + --pids-limit=128 --memory=512m --cpus=1 --tmpfs /tmp:rw,nosuid,nodev,size=128m \ + --mount type=bind,src="$project",dst=/work --mount type=bind,src="$scratch/source.tar.gz",dst=/source.tar.gz,readonly \ + --mount type=bind,src="$PWD/scripts",dst=/scripts,readonly \ + --workdir /work "$image" bun /scripts/safe-extract.ts /source.tar.gz /work + docker run --rm --cap-drop=ALL --security-opt=no-new-privileges \ + --pids-limit=128 --memory=2g --cpus=2 --tmpfs /tmp:rw,nosuid,nodev,size=128m \ + --mount type=bind,src="$project",dst=/work --workdir /work "$image" bun install --frozen-lockfile --ignore-scripts + docker run --rm --network=none --cap-drop=ALL --security-opt=no-new-privileges \ + --pids-limit=128 --memory=2g --cpus=2 --tmpfs /tmp:rw,nosuid,nodev,size=128m \ + --mount type=bind,src="$project",dst=/work --workdir /work "$image" sh -c ' + entrypoint="$(bun -e "const p=await Bun.file(\"package.json\").json();const e=p.temps?.entrypoint;if(typeof e!==\"string\"||!/^src\\/[a-zA-Z0-9_./-]+\\.tsx?$/.test(e)||e.includes(\"..\"))process.exit(1);console.log(e)")" + bun build "$entrypoint" --target=bun --compile --outfile /tmp/temps-plugin-check + ' + echo "Build checked $repo@$commit (install scripts disabled; compile network disabled)" + rm -rf "$project" "$scratch/source.tar.gz" +done +cp "$scratch/plan.json" .catalog-build-plan.json From c0bc29253529a92d1bbe1cbd6decd36646289b85 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Mon, 14 Sep 2026 14:33:46 +0200 Subject: [PATCH 2/3] fix(catalog): pin the merged sidebar-ready template Signed-off-by: David Viejo --- registry/catalog.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/registry/catalog.json b/registry/catalog.json index 0999db7..cf7ed5b 100644 --- a/registry/catalog.json +++ b/registry/catalog.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "generated_at": "2026-09-14T11:12:07.580Z", + "generated_at": "2026-09-14T12:33:31.951Z", "plugins": [ { "name": "my-plugin", @@ -13,7 +13,7 @@ "docsUrl": null, "logoUrl": null, "screenshots": [], - "latestVersion": "0.1.0", + "latestVersion": "0.1.1", "platforms": [ "linux-amd64-gnu", "linux-arm64-gnu", @@ -22,8 +22,8 @@ "darwin-amd64", "darwin-arm64" ], - "commit": "6a8dba40a14061367871bd90220ac17b6cdcbc75", - "readmeUrl": "https://raw.githubusercontent.com/gotempsh/temps-plugin-template/6a8dba40a14061367871bd90220ac17b6cdcbc75/README.md", + "commit": "7506685388e6fbfa73e49ba4cc0293345965f99f", + "readmeUrl": "https://raw.githubusercontent.com/gotempsh/temps-plugin-template/7506685388e6fbfa73e49ba4cc0293345965f99f/README.md", "validation": { "metadata": "passed", "build": "passed" From 0fbed773c29142216766e002fae1302d824c0f57 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Mon, 14 Sep 2026 15:33:09 +0200 Subject: [PATCH 3/3] fix(catalog): match container users to runner file ownership Signed-off-by: David Viejo --- .github/workflows/catalog.yml | 16 ++++++++-------- scripts/catalog-build.test.ts | 17 +++++++++++++++++ scripts/validate-build.sh | 8 ++++---- 3 files changed, 29 insertions(+), 12 deletions(-) create mode 100644 scripts/catalog-build.test.ts diff --git a/.github/workflows/catalog.yml b/.github/workflows/catalog.yml index 8666758..178c172 100644 --- a/.github/workflows/catalog.yml +++ b/.github/workflows/catalog.yml @@ -33,11 +33,11 @@ jobs: fetch-depth: 0 - name: Validate metadata in bounded container run: | - docker run --rm --read-only --cap-drop=ALL --security-opt=no-new-privileges \ + docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --read-only --cap-drop=ALL --security-opt=no-new-privileges \ --pids-limit=128 --memory=512m --cpus=1 --tmpfs /tmp:rw,nosuid,nodev,size=64m \ --mount type=bind,src="$PWD",dst=/work,readonly --workdir /work \ - oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895 bun test scripts/catalog.test.ts scripts/safe-extract.test.ts - docker run --rm --read-only --cap-drop=ALL --security-opt=no-new-privileges \ + oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895 bun test scripts/catalog.test.ts scripts/catalog-build.test.ts scripts/safe-extract.test.ts + docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --read-only --cap-drop=ALL --security-opt=no-new-privileges \ --pids-limit=128 --memory=512m --cpus=1 --tmpfs /tmp:rw,nosuid,nodev,size=64m \ --mount type=bind,src="$PWD",dst=/work,readonly --workdir /work \ oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895 bun scripts/catalog.ts --check @@ -47,7 +47,7 @@ jobs: image='oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895' scratch="$(mktemp -d)" trap 'rm -rf "$scratch"' EXIT - docker run --rm --read-only --cap-drop=ALL --security-opt=no-new-privileges \ + docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --read-only --cap-drop=ALL --security-opt=no-new-privileges \ --pids-limit=128 --memory=512m --cpus=1 --tmpfs /tmp:rw,nosuid,nodev,size=64m \ --mount type=bind,src="$PWD",dst=/work,readonly --workdir /work \ "$image" bun scripts/catalog.ts --plan > "$scratch/plan.json" @@ -59,17 +59,17 @@ jobs: curl --fail --location --silent --show-error --max-time 60 --max-filesize 20971520 \ "https://api.github.com/repos/$repo/tarball/$commit" -o "$scratch/source.tar.gz" test "$(wc -c < "$scratch/source.tar.gz")" -le 20971520 - docker run --rm --cap-drop=ALL --security-opt=no-new-privileges \ + docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --cap-drop=ALL --security-opt=no-new-privileges \ --pids-limit=128 --memory=512m --cpus=1 --tmpfs /tmp:rw,nosuid,nodev,size=128m \ --mount type=bind,src="$project",dst=/work \ --mount type=bind,src="$scratch/source.tar.gz",dst=/source.tar.gz,readonly \ --mount type=bind,src="$PWD/scripts",dst=/scripts,readonly --workdir /work \ "$image" bun /scripts/safe-extract.ts /source.tar.gz /work - docker run --rm --cap-drop=ALL --security-opt=no-new-privileges \ + docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --cap-drop=ALL --security-opt=no-new-privileges \ --pids-limit=128 --memory=2g --cpus=2 --tmpfs /tmp:rw,nosuid,nodev,size=128m \ --mount type=bind,src="$project",dst=/work --workdir /work \ "$image" bun install --frozen-lockfile --ignore-scripts - docker run --rm --network=none --cap-drop=ALL --security-opt=no-new-privileges \ + docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --network=none --cap-drop=ALL --security-opt=no-new-privileges \ --pids-limit=128 --memory=2g --cpus=2 --tmpfs /tmp:rw,nosuid,nodev,size=128m \ --mount type=bind,src="$project",dst=/work --workdir /work "$image" sh -c ' entrypoint="$(bun -e "const p=await Bun.file(\"package.json\").json();const e=p.temps?.entrypoint;if(typeof e!==\"string\"||!/^src\\/[a-zA-Z0-9_./-]+\\.tsx?$/.test(e)||e.includes(\"..\"))process.exit(1);console.log(e)")" @@ -92,7 +92,7 @@ jobs: run: bash scripts/validate-build.sh - name: Regenerate catalog in isolated container run: | - docker run --rm --cap-drop=ALL --security-opt=no-new-privileges \ + docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --cap-drop=ALL --security-opt=no-new-privileges \ --pids-limit=128 --memory=512m --cpus=1 --tmpfs /tmp:rw,nosuid,nodev,size=64m \ --mount type=bind,src="$PWD/registry",dst=/work/registry \ --mount type=bind,src="$PWD/.catalog-build-plan.json",dst=/work/.catalog-build-plan.json,readonly \ diff --git a/scripts/catalog-build.test.ts b/scripts/catalog-build.test.ts new file mode 100644 index 0000000..e18bd90 --- /dev/null +++ b/scripts/catalog-build.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test"; + +for (const path of ["../.github/workflows/catalog.yml", "./validate-build.sh"]) { + test(`${path}: catalog containers use the bind-mount owner's identity`, async () => { + const source = await Bun.file(new URL(path, import.meta.url)).text(); + const commands = source.split("\n").filter(line => line.includes("docker run --rm")); + expect(commands.length).toBeGreaterThan(0); + for (const command of commands) { + expect(command).toContain('--user "$(id -u):$(id -g)"'); + expect(command).toContain('-e HOME=/tmp'); + expect(command).toContain('--cap-drop=ALL'); + expect(command).toContain('--security-opt=no-new-privileges'); + } + expect(source).toContain('--network=none'); + expect(source).toContain('--ignore-scripts'); + }); +} diff --git a/scripts/validate-build.sh b/scripts/validate-build.sh index 5ded811..a978936 100644 --- a/scripts/validate-build.sh +++ b/scripts/validate-build.sh @@ -5,7 +5,7 @@ image='oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232a scratch="$(mktemp -d)" trap 'rm -rf "$scratch"' EXIT -docker run --rm --read-only --cap-drop=ALL --security-opt=no-new-privileges \ +docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --read-only --cap-drop=ALL --security-opt=no-new-privileges \ --pids-limit=128 --memory=512m --cpus=1 --tmpfs /tmp:rw,nosuid,nodev,size=64m \ --mount type=bind,src="$PWD",dst=/work,readonly --workdir /work \ "$image" bun scripts/catalog.ts --plan > "$scratch/plan.json" @@ -21,15 +21,15 @@ jq -c '.[]' "$scratch/plan.json" | while IFS= read -r item; do echo 'Archive exceeds 20 MiB compressed limit' >&2 exit 1 fi - docker run --rm --cap-drop=ALL --security-opt=no-new-privileges \ + docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --cap-drop=ALL --security-opt=no-new-privileges \ --pids-limit=128 --memory=512m --cpus=1 --tmpfs /tmp:rw,nosuid,nodev,size=128m \ --mount type=bind,src="$project",dst=/work --mount type=bind,src="$scratch/source.tar.gz",dst=/source.tar.gz,readonly \ --mount type=bind,src="$PWD/scripts",dst=/scripts,readonly \ --workdir /work "$image" bun /scripts/safe-extract.ts /source.tar.gz /work - docker run --rm --cap-drop=ALL --security-opt=no-new-privileges \ + docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --cap-drop=ALL --security-opt=no-new-privileges \ --pids-limit=128 --memory=2g --cpus=2 --tmpfs /tmp:rw,nosuid,nodev,size=128m \ --mount type=bind,src="$project",dst=/work --workdir /work "$image" bun install --frozen-lockfile --ignore-scripts - docker run --rm --network=none --cap-drop=ALL --security-opt=no-new-privileges \ + docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --network=none --cap-drop=ALL --security-opt=no-new-privileges \ --pids-limit=128 --memory=2g --cpus=2 --tmpfs /tmp:rw,nosuid,nodev,size=128m \ --mount type=bind,src="$project",dst=/work --workdir /work "$image" sh -c ' entrypoint="$(bun -e "const p=await Bun.file(\"package.json\").json();const e=p.temps?.entrypoint;if(typeof e!==\"string\"||!/^src\\/[a-zA-Z0-9_./-]+\\.tsx?$/.test(e)||e.includes(\"..\"))process.exit(1);console.log(e)")"