diff --git a/apps/bridge/src/services/__tests__/marketplace-plugin-pin.test.ts b/apps/bridge/src/services/__tests__/marketplace-plugin-pin.test.ts new file mode 100644 index 0000000..016667c --- /dev/null +++ b/apps/bridge/src/services/__tests__/marketplace-plugin-pin.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { + assertPluginInstallPin, + isFloatingPluginRef, + resolvePluginPinPolicy, +} from "../marketplace-plugin-pin.js"; +import type { CatalogEntry } from "../marketplace-catalog.js"; + +function entry(partial: Partial): CatalogEntry { + return { + id: "demo-plugin", + kind: "plugin", + installType: "plugin", + title: "Demo", + description: "", + version: "1.0.0", + author: "test", + pluginRepo: "https://github.com/example/demo.git", + ...partial, + }; +} + +describe("marketplace-plugin-pin", () => { + it("treats main/master/empty as floating", () => { + expect(isFloatingPluginRef(undefined)).toBe(true); + expect(isFloatingPluginRef("main")).toBe(true); + expect(isFloatingPluginRef("MASTER")).toBe(true); + expect(isFloatingPluginRef("v1.2.3")).toBe(false); + expect(isFloatingPluginRef("abc1234")).toBe(false); + }); + + it("requires pins for Official/Community and SaaS-shaped sources", () => { + expect( + resolvePluginPinPolicy({ + entry: entry({ sourceName: "Official" }), + }) + ).toBe("required"); + expect( + resolvePluginPinPolicy({ + entry: entry({ sourceName: "Community" }), + }) + ).toBe("required"); + expect( + resolvePluginPinPolicy({ + entry: entry({}), + sourceCatalog: + "https://app.godmode.software/api/marketplace/commerce/catalog/official/public", + }) + ).toBe("required"); + expect( + resolvePluginPinPolicy({ + entry: entry({ pluginLocalPath: "/tmp/local-plugin" }), + }) + ).toBe("optional"); + }); + + it("fail-closes Official installs without a pinned pluginRef", () => { + expect(() => + assertPluginInstallPin(entry({ sourceName: "Official", pluginRef: "main" }), "required") + ).toThrow(/pinned pluginRef/); + expect(() => + assertPluginInstallPin(entry({ sourceName: "Official" }), "required") + ).toThrow(/pinned pluginRef/); + }); + + it("accepts tag or commit pins", () => { + expect( + assertPluginInstallPin( + entry({ pluginRef: "v1.0.0", pluginDigest: "deadbeefcafebabe" }), + "required" + ) + ).toEqual({ ref: "v1.0.0", digest: "deadbeefcafebabe" }); + expect( + assertPluginInstallPin(entry({ pluginRef: "abcdef1" }), "required") + ).toEqual({ ref: "abcdef1", digest: undefined }); + }); +}); diff --git a/apps/bridge/src/services/marketplace-catalog.ts b/apps/bridge/src/services/marketplace-catalog.ts index 05c06d8..bffb375 100644 --- a/apps/bridge/src/services/marketplace-catalog.ts +++ b/apps/bridge/src/services/marketplace-catalog.ts @@ -1,6 +1,5 @@ import fs from "node:fs"; import path from "node:path"; -import { execSync } from "node:child_process"; import { v4 as uuidv4 } from "uuid"; import { config } from "../config.js"; import { getCoreDb, type CoreDatabase } from "../core-db.js"; @@ -24,6 +23,11 @@ import { hasPaidEntitlementForCatalogEntry, MarketplaceCommerceError, } from "./marketplace-commerce.js"; +import { + assertPluginInstallPin, + materializePinnedPluginCheckout, + resolvePluginPinPolicy, +} from "./marketplace-plugin-pin.js"; export type CatalogInstallType = "clone" | "plugin"; @@ -38,7 +42,10 @@ export interface CatalogEntry { tags?: string[]; bundlePath?: string; pluginRepo?: string; + /** Immutable git tag or commit for Official/Community installs (#177). */ pluginRef?: string; + /** Optional full/prefix commit sha; fail closed if HEAD drifts (#177). */ + pluginDigest?: string; /** Install from an existing local directory (no git clone). */ pluginLocalPath?: string; previewPath?: string; @@ -446,9 +453,18 @@ export async function uninstallDiscoveredPlugin( async function installPluginEntry( core: CoreDatabase, tenantId: string, - entry: CatalogEntry -): Promise<{ pluginId: string; pluginRoot: string; restartRequired: boolean; built: boolean }> { - const ref = entry.pluginRef ?? "main"; + entry: CatalogEntry, + sourceCatalog?: string +): Promise<{ + pluginId: string; + pluginRoot: string; + restartRequired: boolean; + built: boolean; + pluginRef: string; + pluginDigest?: string; +}> { + const policy = resolvePluginPinPolicy({ entry, sourceCatalog }); + const pin = assertPluginInstallPin(entry, policy); const dirName = entry.id.replace(/[^a-z0-9-]/gi, "-"); let target: string; let built = false; @@ -472,20 +488,13 @@ async function installPluginEntry( } target = path.join(marketplacePluginsDir(), dirName); const cloneUrl = authenticatedGitCloneUrl(entry.pluginRepo); - if (fs.existsSync(target)) { - try { - execSync("git pull", { cwd: target, stdio: "pipe" }); - } catch { - fs.rmSync(target, { recursive: true, force: true }); - execSync(`git clone --depth 1 --branch ${ref} ${cloneUrl} ${target}`, { - stdio: "pipe", - }); - } - } else { - execSync(`git clone --depth 1 --branch ${ref} ${cloneUrl} ${target}`, { - stdio: "pipe", - }); - } + materializePinnedPluginCheckout({ + target, + cloneUrl, + ref: pin.ref, + digest: pin.digest, + entryId: entry.id, + }); if (!bridgeEntryExists(target)) { await ensurePluginBuilt(target, { tenantId, @@ -504,6 +513,8 @@ async function installPluginEntry( pluginRoot: target, restartRequired: false, built, + pluginRef: pin.ref, + pluginDigest: pin.digest, }; } @@ -567,7 +578,12 @@ export async function installCatalogEntry( let result: Record; if (entry.installType === "plugin") { - result = await installPluginEntry(core, opts.tenantId, entry); + result = await installPluginEntry( + core, + opts.tenantId, + entry, + opts.sourceCatalog ?? catalogUrl + ); } else { const bundle = await fetchBundleJson(entry, index, catalogUrl); const imported = importEntity(tenantDb, bundle); diff --git a/apps/bridge/src/services/marketplace-plugin-pin.ts b/apps/bridge/src/services/marketplace-plugin-pin.ts new file mode 100644 index 0000000..db67bba --- /dev/null +++ b/apps/bridge/src/services/marketplace-plugin-pin.ts @@ -0,0 +1,160 @@ +/** + * Buyer protection (#177): pin Official/Community plugin installs to an + * immutable git ref (tag or commit). Fail closed on floating refs and digest drift. + */ +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import type { CatalogEntry } from "./marketplace-catalog.js"; +import { config } from "../config.js"; + +const FLOATING_REFS = new Set(["", "main", "master", "head", "origin/main", "origin/master"]); + +export type PluginPinPolicy = "required" | "optional"; + +export function normalizePluginRef(ref: string | undefined | null): string { + return String(ref ?? "").trim(); +} + +export function isFloatingPluginRef(ref: string | undefined | null): boolean { + const normalized = normalizePluginRef(ref).toLowerCase(); + return FLOATING_REFS.has(normalized); +} + +export function isCommitLikeRef(ref: string): boolean { + return /^[0-9a-f]{7,40}$/i.test(ref.trim()); +} + +/** + * Official and Community catalog installs must pin. Local folder installs and + * operator third-party Local catalogs may omit a pin (self-host convenience). + */ +export function resolvePluginPinPolicy(opts: { + entry: CatalogEntry; + sourceCatalog?: string; +}): PluginPinPolicy { + if (opts.entry.pluginLocalPath?.trim()) return "optional"; + const sourceName = String(opts.entry.sourceName ?? "").toLowerCase(); + if (sourceName.includes("official") || sourceName.includes("community")) { + return "required"; + } + const src = String( + opts.sourceCatalog ?? opts.entry.sourceCatalog ?? "" + ).toLowerCase(); + if ( + src.includes("/catalog/official") || + src.includes("commerce/catalog/official") || + src.includes("godmode-marketplace") + ) { + return "required"; + } + if (config.isSaas && opts.entry.pluginRepo?.trim()) return "required"; + return "optional"; +} + +export function assertPluginInstallPin( + entry: CatalogEntry, + policy: PluginPinPolicy +): { ref: string; digest?: string } { + const ref = normalizePluginRef(entry.pluginRef); + const digest = normalizePluginRef( + (entry as CatalogEntry & { pluginDigest?: string }).pluginDigest + ); + + if (policy === "required") { + if (isFloatingPluginRef(ref)) { + throw new Error( + `Catalog entry "${entry.id}" is missing a pinned pluginRef (tag or commit). ` + + `Floating refs like main/master are not allowed for Official/Community installs (#177).` + ); + } + } + + const effectiveRef = ref || "main"; + if (policy === "optional" && isFloatingPluginRef(ref) && !digest) { + return { ref: effectiveRef }; + } + if (digest && !/^[0-9a-f]{7,40}$/i.test(digest)) { + throw new Error( + `Catalog entry "${entry.id}" has an invalid pluginDigest (expected hex commit sha).` + ); + } + return { ref: effectiveRef, digest: digest || undefined }; +} + +export function readGitHead(pluginRoot: string): string { + return execSync("git rev-parse HEAD", { + cwd: pluginRoot, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + }).trim(); +} + +export function assertGitHeadMatchesDigest( + pluginRoot: string, + digest: string, + entryId: string +): void { + const head = readGitHead(pluginRoot).toLowerCase(); + const want = digest.trim().toLowerCase(); + if (head === want || head.startsWith(want) || want.startsWith(head)) return; + throw new Error( + `Pinned digest mismatch for "${entryId}": expected ${digest}, got ${head}. Fail closed (#177).` + ); +} + +/** Clone or reset a plugin checkout to an immutable ref; verify digest when set. */ +export function materializePinnedPluginCheckout(opts: { + target: string; + cloneUrl: string; + ref: string; + digest?: string; + entryId: string; +}): void { + const { target, cloneUrl, ref, digest, entryId } = opts; + const run = (cmd: string, cwd?: string) => + execSync(cmd, { cwd, stdio: "pipe", encoding: "utf8" }); + + const freshClone = () => { + if (fs.existsSync(target)) { + fs.rmSync(target, { recursive: true, force: true }); + } + fs.mkdirSync(path.dirname(target), { recursive: true }); + if (isCommitLikeRef(ref)) { + run(`git clone --depth 1 ${shellQuote(cloneUrl)} ${shellQuote(target)}`); + run(`git fetch --depth 1 origin ${shellQuote(ref)}`, target); + run(`git checkout --force ${shellQuote(ref)}`, target); + } else { + run( + `git clone --depth 1 --branch ${shellQuote(ref)} ${shellQuote(cloneUrl)} ${shellQuote(target)}` + ); + } + }; + + if (!fs.existsSync(target)) { + freshClone(); + } else { + try { + run("git rev-parse --is-inside-work-tree", target); + run(`git fetch --depth 1 origin ${shellQuote(ref)}`, target); + if (isCommitLikeRef(ref)) { + run(`git checkout --force ${shellQuote(ref)}`, target); + } else { + run(`git checkout --force FETCH_HEAD`, target); + } + } catch { + freshClone(); + } + } + + if (digest) { + assertGitHeadMatchesDigest(target, digest, entryId); + } else if (isCommitLikeRef(ref)) { + assertGitHeadMatchesDigest(target, ref, entryId); + } +} + +function shellQuote(value: string): string { + if (/^[A-Za-z0-9_./:@+-]+$/.test(value)) return value; + return `"${value.replace(/"/g, '\\"')}"`; +} diff --git a/docs/MARKETPLACE.md b/docs/MARKETPLACE.md index 68985a2..24f11c9 100644 --- a/docs/MARKETPLACE.md +++ b/docs/MARKETPLACE.md @@ -39,6 +39,19 @@ Point non-SaaS installs at the public URL with `MARKETPLACE_SAAS_OFFICIAL_URL` / Open **Marketplace → Official** to browse. Free entries install immediately. Paid entries require checkout (card / PayPal / crypto), then **Install if owned**. +### Buyer install pins (#177) + +Official and Community **plugin** installs fail closed unless the catalog entry +sets an immutable `pluginRef` (release tag or commit sha). Optional +`pluginDigest` (commit sha) must match `git rev-parse HEAD` after checkout. +Floating refs (`main` / `master`) are rejected for those catalogs. Local folder +registration (self-host / Unofficial) stays operator-trusted and does not use +this pin gate. + +Intake CI smoke does not replace install pins or runtime least privilege. +Runtime capability allowlists (network deny-by-default for plugins) are tracked +as follow-up #290. + ## Community (user-to-user) 1. Seller: **Sell** → accept ToS → connect payout (required for paid) → publish with kind, title, price, delivery (`clone` or `live`), and source resource id. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 8536703..dd680fd 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -88,6 +88,8 @@ Layers 1–3 (plus shared PTY #162) and `cursor_cloud` SDK sandbox (#171) are a **Layer 4** (SaaS staging/prod recommended via #178): ephemeral npm builds via a host build supervisor (`deploy/build-supervisor/`). Prefer a **separate supervisor or token per data root** so SaaS builds never bind private_hub tenant paths. Default build network is `none`; staging/prod demos use `allowlist` (Docker `--internal` network + host CONNECT proxy). Residual risks: the supervisor is a privileged Docker client on the host; compose publishes supervisor/egress ports on all host interfaces so Linux Bridge containers can reach them via `host.docker.internal` (loopback-only publish breaks host-gateway); operators must firewall WAN NICs and rotate `CODING_BUILD_SUPERVISOR_TOKEN`; a same-named non-internal Docker network fails closed until removed. Non-HTTP egress (SSH / `git@` / arbitrary TCP) is deferred for SaaS defaults (#173): use HTTPS remotes for private git; trusted single-tenant hosts may set `CODING_TERMINAL_NET=shared` if they truly need SSH. Full `shared` build networks remain out of scope. Shared-host VM-grade isolation is #172. | Local plugin path registration | Tenant RCE via arbitrary folders | Blocked on SaaS unless `PLATFORM_SAAS_ALLOW_LOCAL_PLUGINS` | +| Marketplace Official/Community plugin install | Floating `main` pulls or digest drift after intake verify | **Buyer pin (#177):** install requires immutable `pluginRef` (tag or commit); optional `pluginDigest` must match `HEAD`. No `git pull` to latest. Local folder installs remain operator-trusted. Runtime capability allowlists are a follow-up | +| Installed plugin code in Bridge process | Malicious plugin APIs against tenant data after install | Distinct from coding jail (#112) and from Marketplace intake CI. Kill switches (#96). True plugin process/container sandbox is optional later | | Federation API token | Remote command injection if token leaks | Rotate tokens; restrict network access | | First signup admin | Race on internet-exposed fresh installs | Use invite codes, paywall, or pre-seed `INITIAL_ADMINS` | | Plugin bundles (`/api/plugins/*/web.js`) | Proprietary JS exposure | Requires authenticated tenant + installed plugin | @@ -172,6 +174,24 @@ Paid Marketplace plugins are still host-privileged code once installed — revie sources before install. Chargebacks on delivered software ban Marketplace access per [MARKETPLACE_TOS.md](MARKETPLACE_TOS.md). +## Marketplace buyer protection (threat boundaries) + +Intake verification (Marketplace CI smoke / optional review) raises the floor but +does **not** guarantee benign runtime behavior after install. Keep these +boundaries distinct: + +| Boundary | What it covers | What it does not | +|----------|----------------|------------------| +| **Intake** (Marketplace verify VM / Actions) | Public repo pin, one-shot smoke before listing | Time bombs, env-gated exfil, abuse of allowed APIs on the buyer hub | +| **Buyer install pin (#177)** | Official/Community installs must use immutable `pluginRef` (tag or commit); optional `pluginDigest` fail-closed on drift; no floating `main` / `git pull` | Capability allowlists, confirmations, CSP (follow-up) | +| **Coding jail (#112 Layers 1–4)** | Per-tenant coding root + bubblewrap terminal/helpers on a **shared** Bridge host | Isolating *installed plugin* code (plugins load in the Bridge Node process today) | +| **VM-grade coding jobs (#172)** | Disposable machines for untrusted build/coding jobs | Per-plugin runtime sandbox on the buyer hub | +| **Kill switches (#96)** | Deploy/spend/send/agent emergency stops | Fine-grained plugin capability grants | + +Extending CI smoke or Copilot review on intake is encouraged and still not +sufficient alone. A true plugin-per-process/container sandbox remains optional +and expensive. + ## Reporting Open a private security advisory on GitHub for vulnerabilities in the public core. Do not commit secrets, wallet keys, or operator `.env` files. diff --git a/vitest.config.ts b/vitest.config.ts index 028102c..9e007d8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ "apps/bridge/src/plugins/__tests__/route-hot-reload.test.ts", "apps/bridge/src/services/__tests__/marketplace-acquisition.test.ts", "apps/bridge/src/services/__tests__/marketplace-commerce.test.ts", + "apps/bridge/src/services/__tests__/marketplace-plugin-pin.test.ts", "apps/bridge/src/routes/__tests__/marketplace-listings-query.test.ts", "apps/bridge/src/services/__tests__/release-flow.test.ts", "apps/bridge/src/services/__tests__/github-projects-status-map.test.ts",