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.
This commit is contained in:
@@ -0,0 +1,428 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user