diff --git a/.planning/phases/34-plugin-marketplace/34-SECURITY.md b/.planning/phases/34-plugin-marketplace/34-SECURITY.md new file mode 100644 index 00000000..856a3bdd --- /dev/null +++ b/.planning/phases/34-plugin-marketplace/34-SECURITY.md @@ -0,0 +1,130 @@ +--- +phase: "34" +slug: "plugin-marketplace" +status: verified +# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate) +threats_open: 0 +asvs_level: 1 +created: "2026-11-05" +--- + +# Phase 34 — Security + +> Per-phase security contract: threat register, accepted risks, and audit trail. Evidence roots: FW = `summercms.go` (master 7b8d205), SITE = `../sm-summercmsio-app/plugins/golem15/summercms` (v0.1.0, 0b3fbf4), APP = `../sm-summercmsio-app`, VUE = `../sm-summercmsio-app/vue-summercmsio-app`. T-34-SC is declared in all six plans and counted once (37 unique threats). + +--- + +## Trust Boundaries + +| Boundary | Description | Data Crossing | +|----------|-------------|---------------| +| plugin source → kiosk reader | plugin.go, go.mod and README.md of any directory the CLI is pointed at | Go source, module metadata (high) | +| CLI args → go/git subprocesses | dir and module paths reach `go mod edit`, `go list`, `git describe` | paths, module paths (high) | +| plugin.yaml (module cache / submodule) → summer build | third-party text baked into generated Go source | manifest strings (high) | +| plugin repository → registry and installers | plugin.yaml in a tag is read by summercms.io and `summer plugin:install` | manifest (high) | +| curated git repository → ingester | repository content controlled by plugin maintainers | git objects, README, manifests (high) | +| ingested README → marketplace HTML | third-party markdown rendered on summercms.io | HTML (high) | +| public internet → GET /api/registry/v1 | anonymous, cross-origin readers | listing JSON (low) | +| release script (token) → POST /api/registry/v1/ingest | authenticated, scoped | API token, repo id (high) | +| summercms.io admin → presentation fields and repository URLs | trusted administrators behind the SSH tunnel | URLs, text (medium) | +| registry JSON → CLI → go/git | module paths, versions and repository URLs from a registry | install plan (high) | +| user-supplied git URL → git | private or third-party repositories | URL (high) | +| third-party plugin code → app binary | compiled in after confirmation | Go code (high) | +| CLI → app directory | summer.yaml, go.mod, go.work, .gitmodules, plugins/ | app files (medium) | +| browser (admin SPA) → cabana /plugins endpoints | authenticated administrators | installed list, registry data (medium) | +| cabana → registry | outbound call to an operator-configured URL | registry JSON (medium) | +| registry JSON → admin UI | third-party text shown to administrators | names, descriptions, icons (medium) | +| developer workstation → shared remotes | pushes and tags (user-gated) | commits, tags (medium) | +| tests → developer environment / gate script → sibling repositories | module cache, sibling checkouts, Docker; read-only checks in other repos | test state (low) | + +--- + +## Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status | +|-----------|----------|-----------|----------|-------------|------------|--------| +| T-34-01 | Elevation of privilege | kiosk.ReadIdentity | high | mitigate | FW/modules/kiosk/identity.go:229 AST-only `parser.ParseFile`; `str()` :157-189 accepts constants only; only `go list -m`/`mod download`/`mod edit` run. Test manifest_smoke_test.go:172 | closed | +| T-34-02 | Tampering | exec of go/git with paths/modules | high | mitigate | no shell (derive.go:193-212, plugindirs.go:64,292,335); `--` before positionals (identity.go:443,487; plugindirs.go:63); leading "-" refused (build/manifest.go:497-503, kiosk/manifest.go:145-150) | closed | +| T-34-03 | Tampering | plugin.yaml text in plugins.gen.go | high | mitigate | `strconv.Quote` on every string (build.go:175-251), `format.Source` :254-259; malformed manifest ignored with warning (plugindirs.go:216-236). Test plugininfo_smoke_test.go:142 | closed | +| T-34-04 | Information disclosure | plugins.gen.go | low | mitigate | party.Info has no Dir (party/info.go:26-34) | closed | +| T-34-05 | Denial of service | summer build | medium | mitigate | resolveBuildMeta never fails, ID-only fallback with warning (plugindirs.go:94-109,183-186; build.go:39-42) | closed | +| T-34-06 | Repudiation | stale plugin.yaml | medium | mitigate | kiosk/check.go:77-91 `bytes.Equal` → StaleError, CheckT :107; `--check` at cmd/summer/main.go:474-478 | closed | +| T-34-SC | Tampering | npm/pip/cargo installs (all plans) | high | accept | AR-34-01 | closed | +| T-34-07 | Tampering | hand-edited/stale plugin.yaml | medium | mitigate | plugin.yaml + manifest_test.go calling `kiosk.CheckT` in all 25 plugin repos and sm-pgstripe-plugin/subscriptions | closed | +| T-34-08 | Spoofing | manifest claiming another module/ID | medium | mitigate | module from go.mod, package from dir, ID from plugin.go (derive.go:75-103); ingester re-checks (SITE/registry/ingest.go:283,315-327) | closed | +| T-34-09 | Tampering | app pointers to unpushed commits | high | mitigate | bump commits are gitlinks only (sm-demo-app 5ec5941, APP 668a773); every target SHA on origin default branch with its release tag | closed | +| T-34-10 | Denial of service | consumers resolving unreleased framework minimum | medium | accept | AR-34-02 | closed | +| T-34-11 | Elevation of privilege | ingester git with repo URLs | high | mitigate | SITE/registry/git.go: no shell :87, `GIT_ALLOW_PROTOCOL=https` :21,:75, no system/global config :76-77, no prompt, throwaway HOME; `--` before URLs (ingest.go:191,229,233); https-without-credentials enforced at every write path (models/repository.go:88-136) | closed | +| T-34-12 | Denial of service | malicious repo content | high | mitigate | bare `--filter=blob:none` clone (ingest.go:229); 64 KiB / 512 KiB read caps via LimitReader; per-command timeouts (git.go:85); `RemoveAll(tmp)`; ≤20 manifests per tag | closed | +| T-34-13 | Tampering (XSS) | README on /marketplace | high | mitigate | `breakwater.Render` + Purify at ingest (ingest.go:478; breakwater.go:90-99); page re-sanitizes before v-html and drops markdown (VUE/…/[name].vue:8,24-35,152); smoke asserts `notcontains=alert(` | closed | +| T-34-14 | Spoofing | repo claiming another plugin's ID/module | high | mitigate | curated repos only; identity check ingest.go:315-327; first-listed owner under advisory lock (:428,:449) + UNIQUE plugin_id index. Test registry_smoke_test.go:562 | closed | +| T-34-15 | Elevation of privilege | POST /api/registry/v1/ingest | high | mitigate | `cabana.ResolveBackend` + `Allows(PermissionIngest)` (SITE/controllers/api/registry.go:132-144); uncurated → 404; scope narrowed (token_scopes.go:22); nginx POST-only, 8k body (APP/deploy/nginx/summercms.io.conf:162-177); 4 KiB body cap | closed | +| T-34-16 | Denial of service | ingest-now flooding | medium | mitigate | `throttle:registry-ingest` 10/min (routes_registry.go:37,60-67); unique job by args (ingest.go:569); ls-remote digest before clone | closed | +| T-34-17 | Information disclosure | open CORS on registry API | low | mitigate | `*` origins, GET/HEAD, no credentials (APP/config/http.yaml:16-21); surf refuses credentials with `*` (cors.go:50-51); wire type has no last_error/enabled | closed | +| T-34-18 | Denial of service | public API abuse | medium | mitigate | `throttle:registry-api` 300/min; Cache-Control/ETag/304 (registry.go:208-231); q ≤100, category regex, escaped LIKE (registry/query.go:24-57) | closed | +| T-34-19 | Information disclosure | release script token | medium | mitigate | `read_secret_file` refuses group/world-readable file, never prints (APP/scripts/changelog-post.py:320-328); no journal-token fallback (:338-343); failures report status only | closed | +| T-34-20 | Tampering | stale manifest in published tag | medium | mitigate | `check_plugin_release` (changelog-post.py:354-380): HEAD exactly the tag, clean tree, `plugin:manifest --check`; called before posting (:428) | closed | +| T-34-21 | Denial of service | jobs never run in prod | medium | mitigate | `work_in_serve: true` (APP/config/queue.yaml:5); hourly `--queue` schedule; DEPLOY.md:89,93 checks; smoke.sh:243-249 | closed | +| T-34-22 | Elevation of privilege | registry strings → go get / git clone | high | mitigate | validateStep: `kiosk.ValidVersion`, validateModulePath, package within module (install.go:576-604), called in plan/apply/update/source; https-only no-userinfo repo URLs (source.go:135-152); `go get --`, git `--`; no shell | closed | +| T-34-23 | Elevation of privilege | ext::/file:: via git URL | high | mitigate | `cliGitProtocols = "https:ssh"` (source.go:26) forced in runCLIGit (:46-53); validateGitURL refuses leading "-", `::`, control chars, other schemes, credentials (:83-131) | closed | +| T-34-24 | Tampering | malicious plugin code compiled in | high | accept | AR-34-03 | closed | +| T-34-25 | Tampering | writes outside app root | medium | mitigate | ValidatePluginID / repoNameRe + `underRoot` before move and `submodule add` (source.go:386-474; scaffold.go:581-590) | closed | +| T-34-26 | Denial of service | slow/hostile registry | medium | mitigate | fetchguard TrustedMode, 4 MiB, 10 s (kiosk/client.go:28-29,84-88); no redirects (fetchguard/fetch.go:112); `--offline` | closed | +| T-34-27 | Spoofing | registry pointing at a different module | medium | mitigate | ingester ties module to repo; CLI re-reads plugin.yaml and go.mod from checkout and refuses mismatch (source.go:445-457) | closed | +| T-34-28 | Tampering | summer.yaml damaged by edit | low | mitigate | appendManifestPlugins re-parses and restores original on mismatch (build/manifest.go:206-252) | closed | +| T-34-29 | Elevation of privilege | GET /plugins, /plugins/marketplace | medium | mitigate | backend group (cabana/http.go:345-346); `pluginsPrincipal` 401/403 on `PermissionViewPlugins` (plugins.go:400-438); in security_coverage_test.go:56-57,312-313 | closed | +| T-34-30 | Information disclosure | installed plugin list | low | mitigate | gated by pluginsPrincipal; party.Info has no path | closed | +| T-34-31 | Denial of service | registry slow/down (admin) | medium | mitigate | cabana/plugins.go:132-140 4 s timeout, TTL cache (5m/15m, 1m failures), 4 concurrent, 256 cap; failure answers 200 `unavailable` | closed | +| T-34-32 | Tampering (XSS) | registry text in SPA | medium | mitigate | PluginsView.vue has no v-html, text interpolation only; icons https-only with `referrerpolicy="no-referrer"`; ID regex (plugins.go:147,472,536) | closed | +| T-34-33 | Server-side request forgery | plugins.registry_url | low | accept | AR-34-04 | closed | +| T-34-34 | Tampering | tests writing into siblings/module cache | medium | mitigate | `t.TempDir()` throughout; temp GOMODCACHE (fixtures_test.go:118-122; derive_test.go:283); no `../sm-*` paths in tests | closed | +| T-34-35 | Repudiation | gate passing on silently skipped tests | medium | mitigate | scripts/check-phase34.sh go test -json detector with required names (:142-300); Docker-not-run fails `--all` (:49-51); self-test plants each failure (:416-504) | closed | +| T-34-36 | Information disclosure | release ledger | low | mitigate | 34-release-log.md contains no token values, secrets or Bearer strings | closed | + +*Status: open · closed · open — below high threshold (non-blocking)* +*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open* +*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)* + +--- + +## Accepted Risks Log + +| Risk ID | Threat Ref | Rationale | Accepted By | Date | +|---------|------------|-----------|-------------|------| +| AR-34-01 | T-34-SC | No package is installed or added: Go modules already required, no new npm dependency, reka-ui already a dependency. Confirmed: FW go.mod/go.sum/admin package files unchanged v0.5.3→v0.6.0; SITE's new requires are all in FW v0.6.0. | 34-01..06 PLAN | 2026-11-05 | +| AR-34-02 | T-34-10 | Plugins are not tagged until v0.6.0 exists (release order in 34-06's ledger); existing tags are never moved. Confirmed by tag timestamps (framework v0.6.0 before every plugin tag). | 34-02 PLAN | 2026-11-05 | +| AR-34-03 | T-34-24 | Inherent to compiled plugins; every addition is printed and needs confirmation or `--yes` (D-09); sumdb verification stays on (GONOSUMDB/GOSUMDB/GOPRIVATE/GOINSECURE never set in non-test code). | 34-04 PLAN | 2026-11-05 | +| AR-34-04 | T-34-33 | `plugins.registry_url` is an operator-configured trusted setting (fetchguard TrustedMode by design), no redirects, size-capped; administrators cannot change it from the admin. | 34-05 PLAN | 2026-11-05 | + +*Accepted risks do not resurface in future audit runs.* + +--- + +## Informational (review Info items, not in the register, non-blocking) + +34-REVIEW-FIX covered CR and WR findings only. These remain in the code: + +- IN-01 (near T-34-15): the ingest endpoint does not require `Content-Type: application/json` (SITE/controllers/api/registry.go:145-149). +- IN-02 (near T-34-22/26/33): `kiosk.NewClient` accepts `http://` registries without a warning (FW/modules/kiosk/client.go:74-76). +- IN-04 (near T-34-14): plugin ID ownership goes to the first repo and is never released. +- IN-06 (near T-34-13): the marketplace detail page binds `:href` from `plugin.repository` without a scheme check (VUE/changelog/app/pages/marketplace/[vendor]/[name].vue:135); every write path canonicalizes to https, so exploiting it needs a direct database write. +- IN-05, IN-07: the Nitro SWR cache is keyed by query string; the admin loads marketplace icons from third-party hosts. + +--- + +## Security Audit Trail + +| Audit Date | Threats Total | Closed | Open | Run By | +|------------|---------------|--------|------|--------| +| 2026-11-05 | 37 | 37 | 0 | msd-security-auditor (ASVS L1, block_on high) | + +--- + +## Sign-Off + +- [x] All threats have a disposition (mitigate / accept / transfer) +- [x] Accepted risks documented in Accepted Risks Log +- [x] `threats_open: 0` confirmed +- [x] `status: verified` set in frontmatter + +**Approval:** verified 2026-11-05