Files
HiveGrid-Wireframes/hivegrid-cloud/docs/security/plugin-threat-model.md
T

208 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plugin Runtime Threat Model
**Phase:** Phase 06 — Plugin System (#21 WASM Sandbox, #22 Hooks/Lifecycle, #23 Audit/Docs)
**Author:** Claude Opus 4.7 (1M context)
**Last revised:** 2026-05-19
**Status:** **Internal draft — feeds the external audit-package (B-006).**
**Scope:** Everything a third-party WASM plugin can touch — from manifest parsing through the worker invocation path, the audit pipeline, the lifecycle DB rows, and the tenant-admin UI surface.
This document is the operator-facing input to the external Cure53 / SySS / secfault audit. It enumerates the assets the runtime protects, applies STRIDE per asset, and maps each mitigation to a concrete `file:line` in the codebase. Every claim a mitigation makes must be verifiable by reading the linked source. If you find a mitigation cited here that no longer exists, file the gap in `docs/security/plugin-internal-findings.md` and patch the model in the same commit.
The model intentionally treats the **plugin code as adversarial** — a malicious plugin author, a compromised supply chain, or a buggy plugin that happens to do the wrong thing under load are all in scope. Threats from outside the boilerplate (compromised CI worker, stolen DB credentials, malicious host operator) are _out of scope_ and handled by `docs/security/threat-model.md` (the broader platform model).
---
## 1. Asset catalogue
| ID | Asset | Where it lives | Why it matters |
| ---- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| A-01 | Plugin **WASM binary** | Filesystem on `worker-plugin` pod, loaded via `loadBinary` seam in `apps/worker/src/jobs/plugin-invoke.ts:83` | Untrusted code that executes in-process. Sandbox-escape = host RCE. |
| A-02 | Plugin **manifest** (`Plugin.manifest` JSON) | `prisma/schema/plugins.prisma:19` (`Plugin.manifest`) | Declares which capabilities the plugin is allowed to request. Tampering = privilege escalation. |
| A-03 | **Ed25519 trusted-key allowlist** | Env var `PLUGIN_TRUSTED_PUBLIC_KEYS`, parsed at `packages/plugin-runtime/src/signature.ts:95` (`parseTrustedPublicKeys`) | Trust root for every binary. Compromise = arbitrary code execution under any plugin id. |
| A-04 | **Capability gateway** invocation state | `packages/plugin-runtime/src/capability-gateway.ts:78` (`createCapabilityGateway`) | The single chokepoint between WASM and host I/O. Bypass = arbitrary host calls. |
| A-05 | **PluginCapabilityUse** audit rows | `prisma/schema/plugins.prisma:77` (model `PluginCapabilityUse`) | Tamper-evidence for "what did this plugin do?". Loss = no forensics; manipulation = false alibi. |
| A-06 | Plugin-owned **SQL migrations** | `applyPluginMigration` in `packages/plugin-runtime/src/migrations.ts:200` | DDL run as the host DB role. Bypass = cross-tenant DROP/GRANT. |
| A-07 | **Plugin DB rows** (`Plugin`, `PluginUserConfig`) | `prisma/schema/plugins.prisma:14`, `:48` | Lifecycle state. Tampering = silent auto-enable / wrong key/sha pinning. |
| A-08 | **Tenant install API** | `apps/web/app/routes/api.tenants.$tenant.plugins.tsx:43` (`action`) | Entry-point a tenant admin uses to install plugins. CSRF / permission failure = takeover. |
| A-09 | **Hook outbox events** (`plugin.hook.*`) | `packages/plugin-runtime/src/hooks/emit.ts:91` (`emitPluginHook`) | Carrier between host business writes and plugin invocations. Cross-tenant fan-out is the catastrophic failure. |
| A-10 | **Crash tracker counters** | `packages/plugin-runtime/src/crash-tracker.ts:90` (`createCrashTracker`) + SharedStore keys `plugin:crash:<pluginId>:<tenantId>` | Wrong increment = unwarranted auto-disable (availability) or missed disable (uptime + cost). |
| A-11 | **Sandbox memory** (WebAssembly.Memory cap) | `packages/plugin-runtime/src/runtime.ts:117` | A plugin that grows past the cap can OOM the worker. |
| A-12 | **Plugin output buffer** | `apps/worker/src/jobs/plugin-invoke.ts:248` (sanitization site) | Plugin-controlled bytes flowing into audit/log/Sentry. Risk: PII exfiltration into operator-visible surfaces. |
| A-13 | **Rate-limit counters** | Deps seam `consumeRateLimit` in `apps/worker/src/jobs/plugin-invoke.ts:91` | Without enforcement, a single plugin can starve all other tenants. |
| A-14 | **Plugin worker emergency stop** | Dedicated `worker-plugin` service (`WORKER_QUEUE_GROUP=plugin`) plus queue pause/drain and lifecycle disable controls | Last line of defence. Failure to execute = no emergency-stop. |
---
## 2. STRIDE matrix per asset
Each row is `Asset → Threat → Concrete attack scenario → Code-mapped mitigations`.
Mitigation links use `file:line` for the primary anchor. **Every linked line must exist**; if a refactor moves it, update this table in the same commit. The CI hook in `.gitea/workflows/plugin-threat-model-check.yml` (to be added by the security-findings cleanup) will grep for stale anchors.
### A-01 — Plugin WASM binary
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Spoofing** | Attacker uploads a binary that pretends to come from a trusted publisher. | (1) Ed25519 verify at invoke time: `apps/worker/src/jobs/plugin-invoke.ts:193` calls `verifyPluginSignature` from `packages/plugin-runtime/src/signature.ts:48`. (2) Trusted-key allowlist enforced at `signature.ts:59-61`. (3) SHA-256 of binary must match `manifest.wasmSha256` (`signature.ts:53-56`). | A leaked publisher key. Mitigation: rotate via env update + redeploy worker. Documented in `docs/architecture/plugin-system.md`. |
| **Tampering** | Binary on disk is replaced after install. | SHA-256 is recomputed on **every** invocation (`signature.ts:53`); a tampered binary fails `binary_hash_mismatch` and the plugin-invoke job returns `signature_invalid` (`plugin-invoke.ts:198-205`). | None significant — verification is per-invocation, not per-install. |
| **Repudiation** | Operator/tenant denies running the binary. | Per-invocation audit row is fail-closed in `plugin-invoke.ts`: audit write failure logs `plugin.invoke.audit_failed` and throws so the queue retries/dead-letters. Per-capability-use rows go through `PluginCapabilityUse` outbox; the production plugin worker enables recorder fail-closed mode. | Local/dev callers can still use best-effort capability telemetry when they intentionally leave `failClosed` disabled. |
| **Info disclosure** | Binary embeds sensitive identifiers (publisher secret accidentally bundled). | Out of scope for the runtime. Publisher CI lint can catch (`packages/cli/src/commands/plugins/lint.ts`). | Publisher-side hygiene; not enforceable here. |
| **DoS** | Binary contains an infinite loop or memory-bomb. | (1) Plugin invocation runs in a worker-thread sandbox and `PLUGIN_MAX_WALL_CLOCK_MS` terminates non-yielding workers (`apps/worker/src/jobs/plugin-sandbox-isolated.ts`). (2) Fuel counter is charged per host-fn call (`runtime.ts:170`, gateway `capability-gateway.ts:120`). (3) Memory cap enforced by `WebAssembly.Memory({ maximum })` at `runtime.ts:117-118`. (4) Trap detection branches to `memory_limit_exceeded` / `trap` outcomes (`runtime.ts:176-183`). (5) Per-tenant sliding-window rate limit `consumeRateLimit` (`plugin-invoke.ts:208-212`). | **Per-instruction fuel is still not available in V8.** Pure-WASM loops are bounded by coarse wall-clock termination, not instruction accounting; Wasmtime remains the richer audit target. |
| **Elevation** | Binary uses host functions it did not declare. | Capability gateway rejects undeclared invocations: `capability-gateway.ts:96-105` (`manifestDeclaresCapability` check) throws `CapabilityDeniedError`. | None — closed enum + manifest-declared subset is necessary AND sufficient. |
### A-02 — Plugin manifest
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Spoofing** | Manifest claims `signedBy` of a trusted publisher but the binary is unsigned. | Manifest's `signedBy` is compared against the trusted-key allowlist before signature verification runs (`signature.ts:58-61`). Verification uses the manifest-declared key, then confirms the key is trusted. | None — trust check + verify are atomic in `verifyPluginSignature`. |
| **Tampering** | Manifest is mutated post-install to silently grant new capabilities. | (1) Manifest is canonicalised + included in the signed payload (`signature.ts:54-56` for binary hash; signature scope per `docs/architecture/plugin-system.md`). (2) Install-time F-005 checks reject untrusted publisher keys and run `verifyPluginSignature` against the pre-positioned binary before creating the `Plugin` row. (3) Stored manifest is loaded fresh at invocation (`plugin-invoke.ts:173-181`) — no caching means a mutation requires a row update. App-level plugin lifecycle mutations are gated by `app.plugins.manage`; direct row mutation requires DB access. | None for the signed-binary/install path; direct DB writes remain an operator/DB-access boundary. |
| **Repudiation** | Tenant claims they never approved capability X. | Lifecycle service writes an audit event on install (`PluginLifecycleService.ts:81-100` — see `installPlugin`). The audit-events store is hash-chained. Tenant settings requires explicit list-level capability review plus a server-generated manifest digest before writing missing grants. Grant audit metadata records plugin version, review digest, approved capability names, approved scope JSON and the matching manifest scope snapshot. The plugin `audit` host binding itself emits structured worker log/capability telemetry, not a durable audit-events DB row. | None for the install/grant events themselves. Residual UX work remains around a fuller guided tenant-admin consent dialog — F-001. |
| **Info disclosure** | Manifest contains sensitive metadata. | Description field max 2048 chars (`manifest.ts:61`), strict Zod schema (`.strict()` at `manifest.ts:38, 46, 53, 75`). | None — manifest is operator-visible by design. |
| **DoS** | Manifest declares thousands of capabilities or impossibly long strings. | Closed-enum `PLUGIN_CAPABILITY_NAMES` (`capabilities.ts:13-23`) caps the capability vocabulary at 9 entries. String fields have explicit max lengths in `manifest.ts:60-67`. | None significant. |
| **Elevation** | Manifest invents a new capability name. | `z.enum(PLUGIN_CAPABILITY_NAMES)` (`manifest.ts:43`) rejects anything not in the closed enum. `parsePluginManifest` returns issues; `installPlugin` throws `manifest_invalid` at `PluginLifecycleService.ts:74`. | None — the enum is the trust boundary. |
### A-03 — Ed25519 trusted-key allowlist
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| **Spoofing** | Adversary tries to add a key to the allowlist via runtime path. | Allowlist is **read from env only** at process start (`signature.ts:95-101`); no runtime mutation path exists. Adding requires a pod restart. | Operator must restrict who can edit the worker env (Coolify role). Out of runtime scope. |
| **Tampering** | Env value modified mid-run. | `parseTrustedPublicKeys` returns a fresh `ReadonlyArray` per call. Each worker invocation snapshots `trustedPublicKeysHex` (`plugin-invoke.ts:109`) — mutation requires worker restart. | None. |
| **Repudiation** | Operator denies adding a key. | Coolify env-change history. Not the runtime's job. | Out of scope. |
| **Info disclosure** | Public keys leak. | Public keys are **public** by definition. No risk. | None. |
| **DoS** | Allowlist set to empty / invalid CSV → no plugins can run. | Fail-closed is intentional: invalid hex entries are filtered at `signature.ts:99-100`; empty list → `verifyPluginSignature` returns `public_key_not_trusted` (`signature.ts:59`); invocation returns `signature_invalid`. Operator must intentionally seed the env. | None — fail-closed by design. |
| **Elevation** | Wrong key added (typo) effectively trusts a malicious publisher. | Operator process: keys are added via PR review per the documented runbook in `docs/architecture/plugin-system.md`. | Operator-side; mitigated by review. |
### A-04 — Capability gateway
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| **Spoofing** | Plugin calls a host-fn under a capability it doesn't have. | `manifestDeclaresCapability` check on every invoke (`capability-gateway.ts:96`); throws `CapabilityDeniedError` (`:104`). | None — deny by default. |
| **Tampering** | Plugin tries to mutate the `hostFns` map after gateway creation. | Map is captured by closure at `capability-gateway.ts:79`; no exposed mutator. | None. |
| **Repudiation** | Denied call leaves no audit trace. | `onCall` notifier fires for `denied` outcome (`capability-gateway.ts:98-103`); the recorder buffers it for the batch audit-write (`audit.ts:108-121`). | Audit sink can be down — see A-05 residual. |
| **Info disclosure** | Error message leaks host internals. | `CapabilityDeniedError.message` is `"capability denied: <name> (<reason>)"` only (`capability-gateway.ts:56`). No stack/host internals exposed to the plugin. | None. |
| **DoS** | Plugin spams capability calls to drain CPU. | Fuel charged per call (`:120`); `fuelCharged()` records host-fn fuel for audit/limits planning. Default cost 100 fuel/call (`:71`). Invocation wall-clock is bounded by `PLUGIN_MAX_WALL_CLOCK_MS`. | Fuel cap is host-fn-based, not per-instruction. F-002 remains the Wasmtime upgrade track. |
| **Elevation** | `notifyCall` throws and tears down the host before the capability result is returned. | Try/catch isolates the synchronous notifier (`capability-gateway.ts:85-90`), then production flush handles durable-sink failure at sandbox close with `failClosed: true`. | The immediate host-fn result is not interrupted by notifier bugs; production still fails closed before acking the invocation. |
### A-05 — PluginCapabilityUse audit rows
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Spoofing** | Attacker writes rows with another tenant's id. | Recorder is invocation-scoped — `tenantId` set once at construction (`audit.ts:114-119`) and copied into every record. Insertion goes through `PluginCapabilityUseStore.insertMany` (`apps/worker/src/jobs/plugin-capability-use.ts:39`), itself called only from the worker handler. | None at the runtime layer. The outbox queue must remain operator-only writeable. |
| **Tampering** | Rows modified after-write to hide an exfiltration. | Postgres-level: only the schema-owner role can `UPDATE` directly. App-level: there is no `UPDATE PluginCapabilityUse` code path. Rows are hash-chained per tenant through `prevHash` / `rowHash`, and operators verify integrity with `toolctrl-cli plugins verify-audit-chain <tenantId>`. | Keep the verifier in the production runbook; a web endpoint remains deferred. |
| **Repudiation** | Operator/tenant denies an action. | Two-tier evidence: per-invocation row (`Plugin.installedAt` + audit-events table for install) **and** per-capability-use row. | None. |
| **Info disclosure** | Body content stored in audit → DSGVO violation. | **Bodies are NEVER stored.** `payloadHash` is SHA-256 of an arg-shape descriptor only (`audit.ts:173-175`). No URL bodies, no DB row payloads. | None when used as designed. Plugin authors writing custom `descriptor` strings that contain PII are a separate problem — `hashCapabilityPayload` should be the only path. |
| **DoS** | Plugin spams the recorder until heap exhausts. | `maxEntries` cap (default 1024) per invocation (`audit.ts:91, 109`); excess entries are dropped with a `warn` log. | None. |
| **Elevation** | A failed audit write surfaces as a plugin error. | Recorder callers choose the failure mode. The production plugin worker passes `failClosed: true`, so sink failures throw during sandbox finalization and the invocation is recorded as failed instead of silently succeeding. | Local/dev callers can intentionally leave best-effort telemetry enabled; production bootstrap must not. |
### A-06 — Plugin-owned SQL migrations
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Spoofing** | Plugin migration claims to create `plugin_acme_*` but actually creates `users`. | Regex extraction of the table name + prefix check (`migrations.ts:99-104`). Reject = `prefix_violation`. | The regex requires the simple `CREATE TABLE name ( ... )` shape; nested CTEs / `CREATE TABLE LIKE` are rejected outright by the splitter at `migrations.ts:174-180`. |
| **Tampering** | Migration uses `pg_*` system functions to mutate operator data. | `FORBIDDEN_TOKENS` list at `migrations.ts:69-81` catches `GRANT`, `REVOKE`, `CREATE USER`, `ALTER USER`, `DROP USER`, `DROP DATABASE`, `DROP SCHEMA`, `COPY`, `TRUNCATE`, `SET ROLE`, `PG_`. Comments stripped before scan (`migrations.ts:61-67`). | Token-list approach; a clever encoding could in principle smuggle. **F-006** — additionally compare via `pg_query_emscripten` AST for defence in depth (deferred). |
| **Repudiation** | Plugin author claims they didn't intend the bad migration. | Migration SQL is part of the manifest (in #22 follow-up — stored alongside Plugin row JSON). Migration apply is audited via `applyPluginMigration` logger (`migrations.ts:218-222`). **Gap:** no per-statement audit row. | F-007 — per-statement audit write before execution. |
| **Info disclosure** | Migration `SELECT`s from another tenant's table. | Only `CREATE TABLE` and `CREATE INDEX` are allowed (`migrations.ts:171-181`). `SELECT` is rejected as `forbidden_statement`. | None. |
| **DoS** | Migration creates 1000 tables. | Statement count is not capped — a manifest can list arbitrarily many statements. **F-008** — add `MAX_STATEMENTS_PER_MIGRATION` cap (default 32). | Currently bounded by manifest size limits only. |
| **Elevation** | Migration creates a table without `tenantId`, then plugin reads all rows. | Body must contain `\btenantId\b` (`migrations.ts:106-110`); rejection is `missing_tenant_id_column`. | Regex matches in any context (comment, default value). Acceptable: false positives are operator-visible failures; false negatives are blocked because the runtime never disables this check. |
### A-07 — Plugin DB rows (Plugin / PluginUserConfig)
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | ---------------------- |
| **Spoofing** | Tenant A's row claims to belong to tenant B. | `@@unique([tenantId, pluginId])` at `plugins.prisma:31`; install path captures `tenantId` from URL through `getTenantIdFromUrl` (`api.tenants.$tenant.plugins.tsx:37`). | None at the row level. |
| **Tampering** | Tenant admin disables auto-disable by editing the row. | Direct row mutation requires DB access. App-level mutators go through `PluginLifecycleService.enable/disable` which audit. No `update enabled` endpoint exists outside `op=enable | disable` (`api.tenants.$tenant.plugins.tsx:54-61`). | None at the app layer. |
| **Repudiation** | Who installed the plugin? | `installPlugin` writes an audit event with `actor` (`PluginLifecycleService.ts:81-100`). | None. |
| **Info disclosure** | Listing plugins across tenants. | `listPlugins` scoped by `tenantId` (`PluginLifecycleService.ts:listPlugins`). Loader path verifies `app.plugins.use` for the URL's tenant before returning rows (`api.tenants.$tenant.plugins.tsx:65-69`). | None. |
| **DoS** | Tenant installs 10k plugins → DB bloat + worker thrashing. | `@@unique([tenantId, pluginId])` prevents duplicate-id installs. F-009 is closed: `installPlugin` enforces `PLUGIN_MAX_INSTALLS_PER_TENANT` with default cap 50 and returns `tenant_install_limit_exceeded` as HTTP 429. | None at the default cap; operators can set a non-positive override to disable the cap in controlled environments. |
| **Elevation** | Admin of tenant A enables a plugin owned by tenant B. | `enable/disablePlugin` queries by `pluginRowId` AND `tenantId` (`PluginLifecycleService.ts:142+`). | None. |
### A-08 — Tenant install API
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
| **Spoofing** | CSRF: tricked admin POSTs install on their own tenant. | `validateCSRFToken(request)` (`api.tenants.$tenant.plugins.tsx:46`). JSON clients use the `x-csrf-token` header path so the body remains available for `request.json()`. | F-010 is closed; keep the header-token contract for JSON install clients. |
| **Tampering** | Modified body bypasses validation. | Body parsed as JSON; required fields explicitly checked before install. The lifecycle service then enforces the production runtime gate, Zod manifest validation, inclusive host-version range, trusted-key match, disk binary verification, submitted SHA match, duplicate `pluginId` rejection, and tenant install cap. | None for declared fields. |
| **Repudiation** | Admin denies install. | `resolveActor` captures user id + IP into the audit (`api.tenants.$tenant.plugins.tsx:88-100`). | None. |
| **Info disclosure** | Error message leaks internal state. | `PluginLifecycleError` codes are enumerated (`PluginLifecycleService.ts:35-44`); messages are operator-controlled. | None significant. |
| **DoS** | Repeated installs DOS the DB. | Permission gate `app.plugins.manage` + tenant scoping limit blast radius. F-011 is closed: the install route uses a SharedStore sliding-window limiter keyed by `tenant:<id>` with default `PLUGIN_INSTALL_RATE_LIMIT_PER_MIN=5`; over limit returns 429 + `Retry-After: 60`. | Production fails closed on limiter infrastructure errors by default; fail-open is allowed only when explicitly configured with `PLUGIN_RATE_LIMIT_FAIL_OPEN=true`. |
| **Elevation** | Non-admin user calls install. | `verifyUserHasPermission(request, "app.plugins.manage", tenantId)` (`api.tenants.$tenant.plugins.tsx:72-76`). | None. |
### A-09 — Hook outbox events
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Spoofing** | Producer writes an event for a tenant it doesn't own. | `emitPluginHook` is called only from `.server.ts` host code paths that already have `tenantId` from a permission-gated loader/action. Producer trust = host code trust. | Out of scope; trust is at the host-code call site. |
| **Tampering** | Outbox row mutated before dispatch. | Outbox dispatcher is operator-only; no app-level mutate path. Payload is Zod-validated before publish (`emit.ts:98-101`). | None. |
| **Repudiation** | "We didn't fire that hook." | Outbox row + per-invocation audit at the worker side give two independent timelines. | None. |
| **Info disclosure** | Hook payload over-shares (e.g. user PII). | Each hook has a strict Zod schema with `.strict()` (`registry.ts:36, 46, 57`). Adding a field requires editing the registry. | None at runtime; **schema design discipline** at the host-team level. |
| **DoS** | Hook fan-out explodes (one host write → N plugins → N×M jobs). | Single outbox row per hook write (foundation deviation #3 in `hooks-lifecycle.md`); F-012 is closed: `plugin.hook.*` outbox events route to `outbox.plugin-hook`, worker-side fan-out filters manifest-declared subscribers by `tenantId`, and plugin invocation remains bounded by `consumeRateLimit` per plugin. | Residual accepted: a tenant with many enabled subscribers can still create bursty plugin-invoke traffic within configured per-plugin limits. |
| **Elevation** | Plugin in tenant A receives an event for tenant B. | Each `plugin.invoke` job carries `tenantId` from the source event (`plugin-invoke.ts:48`); worker handler scopes `loadPlugin` by `tenantId` (`:173-181`). Plugin only runs for its own tenant rows. | None. |
### A-10 — Crash tracker counters
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
| **Spoofing** | Attacker increments another tenant's counter to force their auto-disable. | Counter key is `plugin:crash:<pluginId>:<tenantId>` (`crash-tracker.ts:82`). Only `recordFailure` writes; only invoked from `plugin-invoke.ts:157-161` with `payload.tenantId` from the job (not user input on the request path). | Outbox payload trust — see A-09 elevation. |
| **Tampering** | Direct SharedStore writes manipulate the counter. | SharedStore credentials are operator-only. | Out of scope. |
| **Repudiation** | Why was my plugin auto-disabled? | `recordFailure` returns `{ failureCount, autoDisabled }`; logger emits `plugin.crash.auto_disabled` with full context (`crash-tracker.ts:128-133`). Auto-disable also writes a notification (`notify` seam). | None significant. |
| **Info disclosure** | Counter exposes invocation rate. | Counter is per `(plugin, tenant)` — knowing the counter requires SharedStore access. | None. |
| **DoS** | Tenant intentionally triggers 10+ failures to disable a plugin they don't like. | **F-013** — the tracker counts only `sandbox_failed` / `signature_invalid` / `binary_load_failed` (configured at `plugin-invoke.ts:150-155`). A tenant cannot trigger `signature_invalid` from a user-facing path, but they can trigger `sandbox_failed` if they control the plugin input. Acceptable: a tenant disabling their own plugin is not a privilege escalation. |
| **Elevation** | Bypass auto-disable by ensuring the counter resets. | `recordSuccess` decrements (`crash-tracker.ts:138-152`) but only by 1 per success. Threshold-1 successes still leave threshold-1 failures. Auto-disable resets the counter (`crash-tracker.ts:127`) — re-enable starts at zero, which is intentional. | None. |
### A-11 — Sandbox memory
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ---------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Spoofing/Tampering** | Plugin reads/writes outside its memory region. | WebAssembly's memory-safety guarantees + Node's V8 enforce bounds. Trap → `memory_limit_exceeded` (`runtime.ts:176-179`). | V8-implementation bugs are out of plugin's control; tracked by Chrome security advisories. Operator must keep Node.js patched. |
| **DoS** | Memory-bomb grows past the cap. | `maximum` page count enforced by `WebAssembly.Memory({ initial, maximum })` (`runtime.ts:117-118`). | None. |
| **Elevation** | Memory escape to host. | V8 sandbox boundary. **This is the audit's principal Q.** | Mitigated by all the layers above plus operator hardening (seccomp/AppArmor — see Security Notes §worker-isolation in `wasm-sandbox.md`). |
### A-12 — Plugin output buffer
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Info disclosure** | Plugin reads tenant DB rows, packs them into its output, output lands in Sentry. | (1) `sanitizePluginOutput` (`packages/plugin-runtime/src/sanitize.ts`) redacts PANs/IBANs/JWTs/API-keys; field-name awareness catches `password`/`secret`. (2) Applied at `apps/worker/src/jobs/plugin-invoke.ts:248` before persistence. (3) Strictness from env `PLUGIN_OUTPUT_SANITIZATION_STRICTNESS` (default `moderate`). | Regex-based sanitization is best-effort: a plugin that base64-encodes secrets evades the regex. **Sanitization is defence-in-depth — the primary trust boundary is the capability gateway** (no `db.read` → can't read tenant data). |
| **DoS** | Plugin returns a 10 MB string into the audit. | F-014 is closed: `sanitizePluginOutput` truncates large strings and carries a total output budget across the walk (`PLUGIN_SANITIZE_TOTAL_BUDGET_BYTES`, default 1 MiB); once exhausted, further string leaves become `[SIZE_LIMIT]`. | Output-size exhaustion is bounded. Regex sanitization remains best-effort for encoded secrets; see the Info disclosure residual above. |
### A-13 — Rate-limit counters
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| **DoS** | Single tenant exhausts worker capacity. | `consumeRateLimit` seam (`plugin-invoke.ts:208-212`) — when `allowed=false` the handler returns `rate_limit_exceeded` before invoking the sandbox. F-015 is mostly closed: the production plugin worker bootstrap wires a SharedStore-backed limiter, and missing production deps fail loudly rather than silently acknowledging plugin jobs. | Remaining external signoff is the broader production WASM runtime review, not the rate-limiter wiring itself. |
### A-14 — Plugin worker emergency stop
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ---------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| **DoS / availability** | After a sandbox-escape report, runtime must be killable quickly. | Stop/scale the dedicated `worker-plugin` service to zero (or redeploy it with `WORKER_ENABLED=false`), pause/drain `toolctrl.plugin-invoke`, and disable affected plugins through lifecycle controls. | Residual operator risk: this is an operational stop path, not a runtime env flag. Runbooks and deploy tooling must keep `worker-plugin` separately controllable from core workers. |
---
## 3. Cross-cutting invariants
These invariants apply to every asset above. Any code change that weakens an invariant is a security-relevant change and must be flagged in PR description.
1. **`tenantId` is host-injected, never plugin-supplied.** Every plugin-runtime function that takes a tenant id reads it from a host-side source (URL param, job payload from outbox event, etc.). The plugin SDK gives plugins no API to set their own tenant id.
2. **Capabilities are deny-by-default and closed-enum.** `PLUGIN_CAPABILITY_NAMES` is the trust boundary. Adding a capability requires (a) extending the enum, (b) implementing a host-fn, (c) reflecting in the manifest schema, (d) reflecting in this threat model.
3. **Audit sink failure is fail-closed in the production plugin worker.** Invocation audit writes throw on persistence failure, and capability-use telemetry is configured with `failClosed: true` in `plugin-invoke-bootstrap.ts`. Local/dev recorder callers may still keep best-effort telemetry by omitting `failClosed`.
4. **Plugin output is sanitized before it leaves the worker process.** Any new code path that emits plugin-output bytes (OTel spans, Sentry breadcrumbs, additional log statements) must funnel through `sanitizePluginOutput`. Re-verify on every PR touching `plugin-invoke.ts` or future observability wiring.
5. **No live mutation of the trust root.** `PLUGIN_TRUSTED_PUBLIC_KEYS` is read once at boot. Restart-to-rotate is intentional.
6. **Plugin migrations are CREATE-only.** No `UPDATE`/`DELETE`/`ALTER`/`DROP` allowed in the foundation grammar.
7. **Production plugin operations are explicitly gated.** In production, lifecycle/fanout/runtime operations require `PLUGIN_RUNTIME_PRODUCTION_ENABLED=true`; the older runtime-enable flag is not the current gate.
---
## 4. Out-of-scope (handled elsewhere)
- **Container hardening (seccomp/AppArmor, user/group, capability(7) drops on the `worker-plugin` pod).** Tracked in [Plugin Lifecycle Operations](../operations/plugin-lifecycle.md) and the external runtime signoff package.
- **CI worker integrity.** The pipeline that produces these binaries is covered by `docs/security/threat-model.md`.
- **DB row encryption at rest.** Postgres-level — operator responsibility.
- **Marketplace UX, plugin-discovery, plugin-store-rating.** Not built; tracked in Phase 11 backlog.
---
## 5. Change log
| Date | Change | Author |
| ---------- | ----------------------------------------------- | ---------------------------- |
| 2026-05-19 | Initial draft for B-006 external audit package. | Claude Opus 4.7 (1M context) |