Files
backspace/docs/superpowers/specs/2026-07-10-security-scanning-hardening-design.md
Jannis Braun aa5052ba8a docs(security): add scanning/hardening design spec + Plan A implementation plan
Completes the design record on main: Plan B's plan and the federation spec were
already here; this adds the umbrella security spec (source of truth for the
remaining container/web/desktop/remediation workstreams) and Plan A's plan.
2026-07-13 11:36:23 +02:00

429 lines
27 KiB
Markdown

# Security Scanning & Hardening Initiative — Design
**Date:** 2026-07-10
**Status:** Approved (design); pending implementation plan
**Author:** Lead Developer (Backspace)
---
## 1. Motivation
A prospective self-hoster declined to run Backspace with the objection:
> "Security testing: You've made a web app. I am not installing a new webapp that
> is expected to touch the internet without some level of security scanning."
The objection is valid. Investigation of the current state shows Backspace has
solid security **engineering** but no security **assurance infrastructure**:
**Already present (good):**
- `SECURITY.md` with a private vulnerability-disclosure policy.
- Real defensive code: SSRF protection (`packages/server/src/utils/ssrf.ts` — DNS
resolution, private-IP blocking, per-redirect-hop re-validation), HMAC-signed
federation with replay-nonce prevention, sliding-window rate limiters, JWT +
bcrypt, input validation.
- Good secrets hygiene: `.deploy.local` untracked; thorough `.gitignore`
(`.env*`, `*.pem`, `*.key`, `data/`, `*.db`).
- CI (`ci.yml`) running typecheck + build + full test suite.
**Absent (the gap):**
- No SAST, no dependency/CVE scanning, no secret scanning, no container image
scanning, no supply-chain hardening, no Dependabot.
- No visible, verifiable evidence a stranger can audit before trusting the app.
- **No browser-facing hardening** the objection actually cares about: no security
response headers (`@fastify/helmet` absent; bare `Caddyfile`), CORS reflects any
origin with credentials, unsigned desktop autoupdate, no Electron fuses/asar
integrity, no license compliance gate for a dual-licensed (AGPL + commercial)
project.
**Root cause (per the No-Band-Aids principle):** the fix is not a one-off scan. It
is a permanent, automated, and *visible* scanning pipeline wired into CI/CD, plus
remediation of the browser/desktop hardening gaps that continuous scanning would
be embarrassing to leave open.
---
## 2. Goals & Non-Goals
### Goals
1. Continuous, automated scanning on every change: SAST, dependency CVEs, secrets,
container image, license compliance, supply-chain posture.
2. Tiered enforcement: high-confidence, fixable issues **block merge**; the rest are
advisory in the GitHub Security tab. Never wall off merges on unfixable upstream
CVEs.
3. Close the browser-facing and desktop-facing hardening gaps (headers, CORS,
Electron integrity).
4. Publish verifiable evidence: badges, an OpenSSF Scorecard, SBOM/provenance, and
documentation a stranger can read without repo access.
5. Land the whole thing without leaving CI spuriously red: scanners report-only →
remediate → flip enforcement.
### Non-Goals (explicitly out of scope for this initiative)
- **npm provenance / package signing** — every workspace is `"private": true`;
nothing is published to npm. N/A.
- **Purchasing desktop code-signing certificates** — a procurement action (Apple
Developer ~$99/yr, a Windows code-signing cert) that cannot be done in code. We
implement the *code-level* Electron hardening and *document* the signing steps
and certs to buy; we do not fake signing.
- **Full fuzzing harness for federation input** — valuable but multi-week; deferred.
A handful of targeted negative/property tests on `validateExternalUrl` and S2S
JSON parsing is in scope; a standing fuzz harness is not.
- **TLS/cipher configuration** — Caddy already auto-provisions HTTPS with modern
defaults; we add security *headers*, not a TLS overhaul.
---
## 3. Tool Selection & Rationale
Where a "GitHub-native" option and a "committed-workflow" option overlap, we prefer
**committed workflow files** — a self-hoster auditing the repo can read a `.yml`
file; they cannot read repo settings. Settings-only toggles are documented as
required manual steps, never claimed as code.
| Scan class | Choice | Rationale |
|---|---|---|
| SAST | **CodeQL** (committed advanced workflow, default `security` suite to start) | Free for public repos, best TS/JS coverage, `none` build mode sidesteps monorepo/native-module build complexity. `security-extended` deferred to avoid a day-one triage tax. |
| Dependency CVEs | **OSV-Scanner** (blocking CI gate) **+ Dependabot** (auto-upgrade PRs) | OSV-Scanner parses `pnpm-lock.yaml` v9 directly and can fail the build; Dependabot alerts are advisory-only. Two distinct roles, no overlap. **Trivy is NOT used for dependency CVEs** (avoids double-noise). |
| Secrets | **gitleaks** (committed, full history + PR diff) **+** documented native push-protection | gitleaks is the verifiable, blocking, history-aware gate; native push-protection is the complementary pre-commit net for the future. |
| Container image | **Trivy** (image scan, blocking) | SARIF output, `ignore-unfixed: true` for tiered policy, scans the exact GHCR image users pull. |
| IaC/config | **Trivy config** (Dockerfile, docker-compose) | Note: Trivy does **not** lint the `Caddyfile`; the reverse-proxy hardening is done by hand (§6.3). |
| License compliance | **Trivy `--scanners license`** with an allowlist | Dual-licensed AGPL + commercial → a copyleft-incompatible transitive dep is a legal defect. Reuses the Trivy we already run. |
| Supply chain | **SHA-pinned actions + harden-runner (audit) + SBOM + SLSA provenance + OpenSSF Scorecard** | Answers "can I trust the build?" and produces a public Scorecard badge. |
| Dynamic (DAST) | **ZAP baseline** against an ephemeral `docker compose up` (advisory) | Catches missing headers + CORS reflection continuously; the one dynamic check for a "webapp exposed to the internet." |
---
## 4. Architecture — Component Layout
Each workflow file has one clear purpose (mirrors the codebase's module-boundary
principle).
```
.github/
dependabot.yml NEW — pnpm(npm) + github-actions + docker(Dockerfile only)
workflows/
codeql.yml NEW — CodeQL SAST (PR + push main + weekly)
security.yml NEW — gitleaks + OSV-Scanner + Trivy config + Trivy license
scorecard.yml NEW — OpenSSF Scorecard (push main + weekly) → Security tab + badge
dast.yml NEW — ZAP baseline vs ephemeral compose stack (advisory)
docker-publish.yml EDIT — restructure for real image scanning + SBOM + provenance
ci.yml EDIT — harden-runner (audit), tighten permissions
release.yml EDIT — harden-runner (audit), tighten permissions
cla.yml EDIT
deploy-pages.yml EDIT
```
**SHA-pinning applies to EVERY workflow** — the four edited above, `docker-publish.yml`,
and all four new ones (`codeql`/`security`/`scorecard`/`dast`). Pin every `uses:` to a
full commit SHA with a trailing `# vX.Y.Z` comment. (OpenSSF Scorecard's
Pinned-Dependencies check and tag-move attack resistance both require this repo-wide.)
```
Dockerfile EDIT — non-root USER, slim runtime, copy pruned node_modules
Caddyfile EDIT — security response headers
packages/server/src/index.ts EDIT — @fastify/helmet + CSP; tighten CORS
packages/server/package.json EDIT — add @fastify/helmet
packages/web/index.html EDIT — CSP meta (defense in depth for static shell)
packages/desktop/src/main.ts EDIT — will-navigate deny handler
packages/desktop/electron-builder.yml EDIT — @electron/fuses / asar integrity
packages/desktop/package.json EDIT — add @electron/fuses
README.md EDIT — badges + "Security & supply chain" section
SECURITY.md EDIT — "Security testing & assurance" section
docs/systems/security-scanning.md NEW — full pipeline spec
docs/systems/desktop-security.md NEW — Electron hardening + signing procurement
CLAUDE.md EDIT — add subsystem-table rows
```
---
## 5. Policy Engine (tiered enforcement)
| Finding | Action |
|---|---|
| gitleaks secret hit | **Block** (always) |
| OSV-Scanner — fixable HIGH/CRITICAL | **Block** |
| Trivy image — fixable HIGH/CRITICAL (`ignore-unfixed: true`) | **Block** |
| Trivy license — disallowed license | **Block** |
| CodeQL — any alert | Advisory (SARIF → Security tab) |
| OSV/Trivy — unfixable, or medium/low | Advisory (SARIF → Security tab) |
| ZAP baseline (DAST) | Advisory (report artifact) |
| Scorecard | Advisory (score badge + Security tab) |
**Enforcement honesty — two mechanisms, kept separate:**
- **Code-enforced (auditable in the `.yml`):** OSV-Scanner, Trivy, and gitleaks
block via workflow exit codes.
- **Settings-enforced (documented one-time toggles, NOT claimed as code):** CodeQL
merge-blocking (code-scanning merge protection), Dependabot alerts, native
secret-scanning + push protection, and branch protection "require status checks."
These live in `docs/systems/security-scanning.md` as a maintainer checklist.
---
## 6. Workstreams (bounded, independently reviewable)
Sequencing rule: **WS1 scanners land report-only → WS5 remediation → flip WS1/WS2
enforcement to blocking.** WS3/WS4 are otherwise independent and can land in
parallel. Two cross-workstream dependencies to respect: **(a)** WS3 (CSP/CORS
validation) and WS6's DAST job share the same **two-instance + LiveKit ephemeral
test rig** — build it once, reuse it; **(b)** WS6's badges + maintainer checklist
document state produced by WS1/WS2/WS5, so its final copy is written *last* (the
workflow files can be scaffolded earlier).
### WS1 — Scanning & supply-chain pipeline (report-only first)
- `.github/dependabot.yml`:
- `package-ecosystem: npm` at `/` (Dependabot handles pnpm workspaces), weekly,
grouped minor/patch.
- **`ignore` `uiohook-napi`** — it is pinned by an exact-version patch
(`patches/uiohook-napi@1.5.5.patch`); an unmatched bump breaks
`pnpm install --frozen-lockfile` in CI and both Docker stages. Also treat
`onlyBuiltDependencies` (`better-sqlite3`, `esbuild`, `electron`, `sharp`)
bumps with care (grouped, expect native-rebuild churn).
- `package-ecosystem: github-actions` at `/`.
- `package-ecosystem: docker` at `/` — tracks the **Dockerfile `FROM`** only.
**No compose entry:** `docker-compose.yml` sits at the same `/` directory (a
second docker entry there would collide on ecosystem+directory), and Dependabot's
docker ecosystem parses Dockerfiles, **not** `image:` refs in compose. The pinned
`caddy:2.11.1-alpine` / `livekit/livekit-server:v1.9.11` compose images are
therefore updated **manually** — added as a line item to the maintainer checklist
in `docs/systems/security-scanning.md`. (Renovate, which does parse compose, is
noted there as an optional future alternative.)
- `codeql.yml`: languages `javascript-typescript`, default `security` queries,
triggers PR + push `main` + weekly cron. SARIF uploaded.
- `security.yml`:
- **gitleaks** — full history + PR diff, SARIF, **block** on hit.
- **OSV-Scanner** — reads `pnpm-lock.yaml`; report-only initially, then block on
fixable HIGH/CRITICAL after WS5.
- **Trivy config** — Dockerfile + docker-compose misconfig, SARIF, advisory.
- **Trivy license** — `--scanners license` against the dependency tree with an
allowlist (permissive + AGPL-compatible); block on disallowed.
- `scorecard.yml`: `ossf/scorecard-action`, push `main` + weekly, publish results +
badge.
- Harden **all** workflows (new and existing, incl. `docker-publish.yml`): pin every
`uses:` to a full commit SHA (retain a `# vX.Y.Z` comment); add
`step-security/harden-runner` in **`egress-policy: audit`** (not block — multi-arch
buildx + QEMU + gha cache make many egress calls); tighten job-level `permissions`
to least privilege.
### WS2 — Container hardening & real image scanning
- **Restructure `docker-publish.yml`** (the current single multi-arch `build-push`
cannot be scanned before publish):
1. Build **single-arch `linux/amd64`** with `load: true`.
2. **Trivy image scan** (`ignore-unfixed: true`, block on fixable HIGH/CRITICAL),
SARIF uploaded.
3. On pass, the multi-arch (`amd64,arm64`) `build-push` with `push: true`,
`sbom: true`, `provenance: true`. (Buildx cache makes the second build cheap.)
- Trivy authenticates to GHCR with the same `GITHUB_TOKEN` used for login (the
package may be private until manually flipped public).
- **Dockerfile hardening:**
- **Prune mechanics (precise):** the builder runs a *full* `pnpm install
--frozen-lockfile` (Dockerfile:25) whose `node_modules` is a symlinked `.pnpm`
virtual store — a plain `COPY --from=builder node_modules` is **not**
self-contained. Use `pnpm --filter @backspace/server deploy --prod
/app/deploy` in the builder to produce a dereferenced/hoisted prod tree, then
`COPY --from=builder /app/deploy` into the runtime stage. This replaces the
runtime stage's own `pnpm install --prod`, letting `python3 make g++` be dropped
from runtime. **Keep `ffmpeg`** (real runtime dep) and **keep `tsx`** as a prod
dependency (the CMD runs TS via `tsx/esm`). Verify `better-sqlite3`'s prebuilt
binary and `tsx` are present in the copied tree for **both** target arches.
- **Non-root + bind-mount chown (reconciled — the two are mutually exclusive if
done naively):** `docker-compose.yml:31` bind-mounts host-owned `./data:/app/data`.
Chowning it requires **root**, so we do **not** hard-set a `USER` line (that would
run the entrypoint as non-root and make the chown impossible). Instead: install
`gosu` (or `su-exec`), add an `ENTRYPOINT` that (a) idempotently `chown`s
`/app/data` to a fixed non-root UID, then (b) `exec gosu <uid> "$@"` to drop
privileges — so the process runs non-root while the volume stays writable. The
`ENTRYPOINT` must `exec "$@"` to preserve the existing `WORKDIR
/app/packages/server` + `CMD ["node","--import","tsx/esm","src/index.ts"]`
(Dockerfile:96-97). Ship a documented upgrade note; must not break existing
self-hosters on `docker pull` + restart.
### WS3 — Web/server hardening
**Reality check (from review):** this app renders *arbitrary user-supplied content*
and is *federated*, so a restrictive `img-src`/`media-src`/`connect-src` is
infeasible. A CSP here realistically constrains `script-src` / `object-src` /
`base-uri` / `frame-ancestors` / `form-action` (the XSS/clickjacking-relevant
directives) and stays permissive on content origins. Concretely:
- **`img-src` / `media-src` must be broad** (`https: data: blob:`): link-embed OG
images (`VideoEmbed.tsx`, `RichEmbed.tsx`) come from *any* linked site, and GIF
previews load directly from Klipy's CDN (`routes/gif.ts` returns `file.url`
unproxied — the CDN host differs from `api.klipy.com`).
- **`connect-src` must include the LiveKit `wss://` origin, which is operator
config** (`routes/livekit.ts` returns `config.livekit.url` = `LIVEKIT_URL`
verbatim) — so the **CSP must be generated at runtime from config**, not a static
string. Federation (`getApiForOrigin` in `exploreStore`/`socialStore`/`spaceStore`)
fetches/opens WS to peers discovered at runtime → `connect-src` must also allow
`https: wss:` (peers aren't enumerable at build time).
- **`frame-src` needs an explicit provider allowlist** — YouTube, Vimeo, Spotify
embed origins — or the embed iframes break (default `frame-src 'self'` blocks them).
Steps:
- Add `@fastify/helmet`. Build the CSP **dynamically** from `config.livekit.url` +
the embed-provider list; ship it **report-only first**, validate against real flows
(chat, **cross-instance federation**, embed render, upload, and a **real voice
join**) with zero violations, then flip to enforcing.
- `packages/web/index.html`: CSP `<meta>` (defense-in-depth) — script/object/base
directives only; do not duplicate the dynamic connect/img rules there.
- `Caddyfile`: `Strict-Transport-Security`, `X-Content-Type-Options: nosniff`,
`Referrer-Policy`, and clickjacking protection via CSP `frame-ancestors` (prefer
over `X-Frame-Options`). **Ownership split (documented to avoid conflicts):** Caddy
owns HSTS + nosniff + Referrer-Policy; the app (helmet) owns the CSP. Don't set CSP
in two places.
- **CORS (`packages/server/src/index.ts:46-48`)** — replace `origin: true` with a
**dynamic `origin` callback backed by the live federation-peer registry**, NOT a
static `DOMAIN`-derived list. Two breakages a static list would cause, both must be
handled:
- **Federated browser uploads:** browsers make cross-origin tus POST/HEAD/PATCH/
DELETE to peer `/api/files/*` (see the existing CORS-block comment at
`index.ts:50-64`); peers are DB-backed and added after boot → the callback must
consult the live registry, not a boot-time snapshot.
- **Desktop instance picker:** `packages/desktop/resources/instance-picker.html`
does a renderer `fetch('<url>/api/instance/info')` from a `file://` document
(Origin `null`). Keep `/api/instance/info` **CORS-open** (or move that probe to a
main-process fetch) so the picker doesn't report instances as unreachable.
- **Federation note:** S2S endpoints authenticate by HMAC and receive no browser
`Origin`; verify they are unaffected by the two-instance federation integration
suite.
- **Rollout (phased, like the scanners):** CSP report-only → observe → enforce; CORS
gets a **"log-and-allow" observation phase** (log rejected origins without blocking)
before switching to reject. **Test-rig dependency:** validating CSP + CORS here
needs the **two-instance + LiveKit** harness (shared with the DAST env, §WS6/G2),
which is heavier than a single-instance boot — call this out when scheduling.
### WS4 — Desktop/Electron hardening
- **Fuses without breaking the existing hook:** `electron-builder.yml:20` already
declares `afterPack: ./scripts/afterPack.js` (it strips host-compiled
`uiohook-napi` artifacts + cross-platform prebuilds), and electron-builder allows
**only one** `afterPack`. So do **not** add a second hook. Prefer electron-builder's
top-level **`electronFuses:`** config key (cleanest, no collision); if a fuse isn't
expressible there, call `@electron/fuses` `flipFuses()` **inside** the existing
`scripts/afterPack.js`. Fuses: disable `RunAsNode` + `EnableNodeCliInspectArguments`,
enable `OnlyLoadAppFromAsar`. **Asar-integrity caveat:** it interacts with the
existing `asarUnpack: **/*.node` (lines 17-18) and the afterPack that mutates
`app.asar.unpacked` — integrity hashes must be computed *after* those mutations, and
because builds are unsigned (`release.yml:93`) macOS integrity **enforcement** is
limited; document this in `desktop-security.md` rather than over-claiming.
- **`will-navigate` deny handler** in `main.ts`: block foreign top-level navigations
while allowing the initial `https://` instance load and the `file://` picker.
Clarification (mechanism): the app is client-routed (history API →
`did-navigate-in-page`), cross-instance switching uses main-process `loadURL`, and
`/join/*` deep-links are handled by `setWindowOpenHandler` (`main.ts:454`) — none of
these are `will-navigate`, so the deny handler is safe and `setWindowOpenHandler`
stays untouched.
- `docs/systems/desktop-security.md`: document the current webPreferences posture
(contextIsolation on, nodeIntegration off, sandbox on — `main.ts:356-360`), the
fuses/asar posture and its unsigned-macOS limits, and — because `release.yml:93`
sets `CSC_IDENTITY_AUTO_DISCOVERY: false` (unsigned) — the exact signing +
notarization steps and certificates to procure. Flag unsigned autoupdate as a known
gap until signing is wired up.
### WS5 — Remediation (after WS1 lands report-only)
- Run OSV-Scanner + Trivy + CodeQL; triage. Fix real HIGH/CRITICAL: direct upgrades,
`pnpm.overrides` for transitive pins where no direct upgrade exists, code fixes for
true-positive SAST findings. Dismiss false positives **with written justification**
(`.trivyignore` / inline).
- **SSRF hardening (fix, then test — not just test):** the string-prefix
`isPrivateIp` (`utils/ssrf.ts:3-16`) is genuinely bypassable — `::ffff:127.0.0.1`
matches no branch and returns `false` (SSRF to loopback via an attacker AAAA
record), and there is no `100.64.0.0/10` (CGNAT) or `::` handling. **Harden
`isPrivateIp`**: normalize IPv4-mapped IPv6, reject CGNAT and `::`/unspecified, and
normalize decimal/octal/hex hostname encodings — *then* add the negative/property
tests for `validateExternalUrl` covering those vectors. The residual DNS-rebind
TOCTOU is already documented (`ssrf.ts:58-61`) and stays out of scope (noted, not
fixed).
- **Then flip WS1/WS2 enforcement to blocking.**
### WS6 — Visible evidence, DAST & docs
- **`dast.yml` (ZAP baseline):** stands up an ephemeral instance and runs ZAP
baseline (advisory). **CI env override required** — the production compose won't
come up unmodified: Caddy uses `{$DOMAIN}` + ACME auto-HTTPS (hangs in CI without
public DNS), `backspace` requires `JWT_SECRET`, livekit is profile-gated. Use a CI
compose override that sets a test `JWT_SECRET`/`DOMAIN` and **points ZAP directly at
the `backspace` container `:3000`, bypassing Caddy** (or Caddy `internal`/local
TLS). This is the same two-instance-capable rig WS3 needs for CSP/CORS validation.
- README: CodeQL, OpenSSF Scorecard, and security-policy badges; a "Security &
supply chain" section describing what runs on every change and where results are
published.
- SECURITY.md: add a "Security testing & assurance" section enumerating the pipeline.
- `docs/systems/security-scanning.md`: full spec of every workflow, the tiered
policy, and the maintainer settings checklist (§5) — including the **repo-must-be-
public precondition** (Scorecard `publish_results` + badge and CodeQL free tier both
require a public canonical repo) and the manual `caddy`/`livekit` compose-image
update reminder (from WS1/F1).
- CLAUDE.md: add subsystem-table rows for `security-scanning.md` and
`desktop-security.md` (required by the Documentation Rule — this is structural CI
and architecture).
- **Finalize WS6 last:** badges + the maintainer checklist document state that only
exists once WS1/WS2/WS5 land, so write the final copy after those are green (the
workflow *files* can be scaffolded earlier).
---
## 7. Testing Strategy
- **`actionlint`** on every new/edited workflow.
- **Real PR-branch run** watching each check go green (or advisory) as intended.
- **Canary proof of blocking:** on a throwaway branch, introduce a fake secret and a
known-vulnerable dependency; confirm gitleaks and OSV-Scanner actually **fail** the
build; revert.
- **WS2:** `docker build` locally for amd64 + container boots + `/api/health`
responds, before and after the Dockerfile changes; confirm the process runs
**non-root** (via gosu step-down) yet still writes the host-owned `./data` bind
mount; confirm `tsx` + `better-sqlite3` prebuilt are present in the pruned tree and
the arm64 image still builds; confirm existing self-hosters survive `pull` + restart.
- **WS3 (needs the two-instance + LiveKit rig):** security headers present
(curl/DevTools); **zero CSP violations** across chat, **cross-instance federation**,
embed render (YouTube/Vimeo/Spotify + generic OG image), GIF, upload, and a **real
voice join**; CORS callback permits the app origin **and dynamically-registered
peers** (federated upload), keeps `/api/instance/info` open to the `file://` picker
(Origin `null`), and rejects an unknown origin; two-instance federation S2S suite
still green.
- **WS4:** desktop app boots with fuses/asar-integrity applied and the existing
`afterPack` native-module cleanup intact; `will-navigate` blocks a foreign top-level
URL while the initial instance load, the `file://` picker, and `/join/*` deep-links
(via `setWindowOpenHandler`) still work.
- **DAST:** ZAP baseline runs against the CI compose override (bypassing Caddy) and
produces a report artifact.
- **Full suite** (`pnpm -r test`) green throughout; existing federation/voice suites
unaffected.
---
## 8. Risks & Mitigations
| Risk | Mitigation |
|---|---|
| Enforcement day-one paints CI permanently red (May-2024 lockfile has fixable highs) | Report-only → remediate (WS5) → flip blocking. |
| Multi-arch image "scan" is theater / arm64 unscanned | WS2 restructure: single-arch load+scan → then multi-arch push. |
| Non-root USER breaks `./data` bind-mount for existing self-hosters | Run entrypoint as root → chown → `exec gosu <uid>` step-down (no static `USER`); documented upgrade note; tested before/after. |
| Dependabot breaks CI via `uiohook-napi` patch / native rebuilds | `ignore` the patched dep; group `onlyBuiltDependencies`. |
| CSP too strict for a federated, arbitrary-content app | CSP built **dynamically** from `config.livekit.url` + peer registry; `img/media/connect` permissive; constrain only script/object/base/frame-ancestors; report-only → enforce. |
| CORS allowlist breaks federated uploads + desktop `file://` picker | Dynamic `origin` callback backed by the **live peer registry**; keep `/api/instance/info` CORS-open; "log-and-allow" phase before rejecting. |
| Electron fuses overwrite the existing `afterPack` (native-module cleanup) | Use top-level `electronFuses:` key or call `flipFuses()` inside the existing `scripts/afterPack.js`; compute asar-integrity hashes after afterPack mutations. |
| pnpm symlinked `.pnpm` store makes a plain `node_modules` copy non-self-contained | Use `pnpm --filter @backspace/server deploy --prod`; verify `tsx` + `better-sqlite3` prebuilt land per-arch; keep `ffmpeg`; boot test. |
| DAST/compose won't come up in CI (ACME/DOMAIN/JWT_SECRET) | CI compose override with test env; point ZAP at `backspace:3000`, bypass Caddy. |
| harden-runner block mode false-positives the Docker build | Start in `audit`; graduate to block only on lightweight jobs. |
| Scorecard badge / CodeQL free tier assume a public repo | Documented as an explicit precondition in the maintainer checklist. |
---
## 9. Definition of Done
- All new workflows present, `actionlint`-clean, and green on a real PR.
- Blocking gates proven by canary (secret + vuln), then reverted.
- Security tab populated (CodeQL, Scorecard, advisory Trivy/OSV) with no open
fixable HIGH/CRITICAL after WS5.
- helmet + CSP + Caddyfile headers live with no CSP violations in normal use; CORS
allowlisted; federation suite green.
- Electron fuses + `will-navigate` live; desktop boots and deep-links work; existing
`afterPack` native-module cleanup intact.
- `isPrivateIp` hardened (IPv4-mapped IPv6 / CGNAT / `::` / alt-encodings) with
passing negative tests.
- Container image scanned before publish; SBOM + provenance attached; Dockerfile
runs non-root (gosu step-down) with a working data volume.
- README badges + Security section; SECURITY.md expanded;
`docs/systems/security-scanning.md` + `docs/systems/desktop-security.md` written;
CLAUDE.md subsystem table updated.
- Maintainer settings checklist documented (CodeQL merge protection, Dependabot
alerts, push protection, branch protection).