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:
Jannis Braun
2026-07-13 11:36:23 +02:00
parent 97989fbfb4
commit aa5052ba8a
2 changed files with 1073 additions and 0 deletions
@@ -0,0 +1,645 @@
# Plan A — Scanning Pipeline & Supply-Chain Hardening (report-only) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Stand up the full automated security-scanning pipeline (Dependabot, CodeQL SAST, secret scanning, dependency CVEs, IaC/license scanning, OpenSSF Scorecard) plus supply-chain hardening (SHA-pinned actions, harden-runner, least-privilege permissions) on GitHub Actions — all **report-only/advisory**, so the PR that adds it stays green and mergeable.
**Architecture:** Four new files under `.github/` (one Dependabot config + three workflows) each with a single scan responsibility, plus a hardening sweep across the four existing workflows. Every scanner uploads SARIF to the GitHub Security tab and is non-blocking in this plan; enforcement (fail-the-build) is flipped on in a later plan (Plan E) after the remediation pass. This is the foundation the rest of the initiative builds on and, on its own, answers the "no security scanning" objection with visibly-running scanners.
**Tech Stack:** GitHub Actions (YAML), GitHub Dependabot, CodeQL (`javascript-typescript`, build-mode `none`), gitleaks, OSV-Scanner (reads `pnpm-lock.yaml` v9), Aqua Trivy (config + license), OpenSSF Scorecard, StepSecurity harden-runner. Local validators: `actionlint`, `pinact` (SHA-pinning).
## Global Constraints
- **Report-only in this plan.** Every scanner must be non-blocking (`continue-on-error: true` at step level, or advisory SARIF upload). Enforcement is flipped on in Plan E — do NOT make any scanner fail the build here. Each non-blocking step carries a comment: `# report-only; enforcement flipped on in Plan E`.
- **SHA-pin every action.** All `uses:` refs across ALL workflows (new and existing) pin to a full 40-char commit SHA with a trailing `# vX.Y.Z` version comment. No `@v5`/`@main` tag refs may remain after Task 5.
- **harden-runner is Linux-only.** `step-security/harden-runner` runs only on Ubuntu runners. In any matrix that includes macOS/Windows (i.e. `release.yml`), guard it with `if: runner.os == 'Linux'`.
- **`egress-policy: audit`** for every harden-runner step (never `block` in this plan — multi-arch buildx/QEMU/gha-cache make many egress calls).
- **Commit identity:** the repo's local git config already uses `Jannis Braun <151788261+TheZwiss@users.noreply.github.com>` — use a plain `git commit`. NEVER override author/committer email with `-c user.email=...`, and never commit as `alxtrading94@gmail.com`.
- **No new runtime dependencies.** This plan touches only `.github/` and docs; it must not modify `package.json` dependency lists or any application/runtime code.
- **Node 20 / pnpm 10.34.3** are the project's pinned toolchain — any workflow that installs deps mirrors `ci.yml` (`pnpm/action-setup` @ 10.34.3, `actions/setup-node` node 20).
- **Branch:** all work lands on `security-scanning-hardening` (already checked out).
- **Action versions:** the YAML below uses each action's current major tag. If an action's latest major differs at implementation time, check its README and adjust the tag — then Task 5 pins whatever tag you used to its SHA. A wrong tag surfaces as an `actionlint` error or a red PR check; fix and re-run.
---
### Task 1: Dependabot config + local validators
**Files:**
- Create: `.github/dependabot.yml`
**Interfaces:**
- Consumes: nothing (first task).
- Produces: `.github/dependabot.yml` — the Dependabot v2 config later documented by Task 6. No code symbols.
- [ ] **Step 1: Install the local validators**
`actionlint` validates workflow YAML; `pinact` will SHA-pin actions in Task 5. On the macOS dev host:
Run:
```bash
brew install actionlint pinact
actionlint --version && pinact --version
```
Expected: both print a version. (Fallbacks if Homebrew lacks them: `go install github.com/rhysd/actionlint/cmd/actionlint@latest` and `go install github.com/suzuki-shunsuke/pinact/cmd/pinact@latest`, or run actionlint via Docker `docker run --rm -v "$(pwd):/repo" --workdir /repo rhysd/actionlint:latest -color`.)
- [ ] **Step 2: Write `.github/dependabot.yml`**
```yaml
# Dependabot keeps dependencies and CI actions patched. Three ecosystems:
# - npm → the pnpm workspace (Dependabot reads pnpm-lock.yaml v9)
# - github-actions → action version bumps (feeds the SHA-pin comments)
# - docker → the Dockerfile base image (FROM node:20-slim)
#
# NOTE (intentional): there is NO docker entry for docker-compose.yml. It sits
# at the same "/" directory (a second docker entry would collide on
# ecosystem+directory), and Dependabot's docker ecosystem parses Dockerfiles,
# not `image:` refs in compose. The pinned caddy / livekit-server compose images
# are updated MANUALLY — see the maintainer checklist in
# docs/systems/security-scanning.md.
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 10
groups:
# One grouped PR for routine minor/patch bumps to cut PR noise.
npm-minor-patch:
update-types:
- minor
- patch
ignore:
# uiohook-napi is pinned by an exact-version pnpm patch
# (patches/uiohook-napi@1.5.5.patch). A bump makes the patch path stop
# matching, breaking `pnpm install --frozen-lockfile` in CI and both
# Docker stages until the patch is regenerated. Bump it by hand.
- dependency-name: uiohook-napi
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
github-actions:
patterns:
- "*"
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
```
- [ ] **Step 3: Validate YAML syntax**
Run:
```bash
python3 -c "import yaml,sys; yaml.safe_load(open('.github/dependabot.yml')); print('dependabot.yml: valid YAML')"
```
Expected: `dependabot.yml: valid YAML` (no traceback). (The full schema is validated by GitHub after push — Task 7 confirms it in the repo's Insights → Dependency graph → Dependabot.)
- [ ] **Step 4: Commit**
```bash
git add .github/dependabot.yml
git commit -m "ci(security): add Dependabot config (npm + actions + docker)"
```
---
### Task 2: `security.yml` — secret, dependency, IaC & license scanning (report-only)
**Files:**
- Create: `.github/workflows/security.yml`
**Interfaces:**
- Consumes: `pnpm-lock.yaml` (OSV lockfile scan), repo tree (gitleaks history, Trivy config/license).
- Produces: workflow `Security` with jobs `gitleaks`, `osv-scanner`, `trivy-config`, `trivy-license`; each uploads a SARIF category (`gitleaks`, `osv-scanner`, `trivy-config`, `trivy-license`). Task 6 documents these; Task 5 pins their actions.
- [ ] **Step 1: Write `.github/workflows/security.yml`**
```yaml
name: Security
# Report-only in this plan: every scanner is non-blocking and uploads SARIF to
# the Security tab. Enforcement (fail on fixable HIGH/CRITICAL, block on secrets)
# is flipped on in Plan E after the remediation pass.
on:
pull_request:
push:
branches: [main]
schedule:
- cron: '32 5 * * 1' # weekly Monday 05:32 UTC
permissions:
contents: read
concurrency:
group: security-${{ github.ref }}
cancel-in-progress: true
jobs:
gitleaks:
name: Secret scan (gitleaks)
runs-on: ubuntu-latest
steps:
- name: Harden the runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Checkout (full history)
uses: actions/checkout@v5
with:
fetch-depth: 0 # gitleaks scans the whole git history, not just the diff
- name: Run gitleaks
uses: gitleaks/gitleaks-action@v2
continue-on-error: true # report-only; enforcement flipped on in Plan E
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
osv-scanner:
name: Dependency scan (OSV-Scanner)
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # upload SARIF to code scanning
steps:
- name: Harden the runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v5
- name: Run OSV-Scanner
uses: google/osv-scanner-action@v2
continue-on-error: true # report-only; enforcement flipped on in Plan E
with:
scan-args: |-
--lockfile=./pnpm-lock.yaml
--format=sarif
--output=osv-results.sarif
- name: Upload OSV SARIF
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: osv-results.sarif
category: osv-scanner
trivy-config:
name: IaC/config scan (Trivy)
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- name: Harden the runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v5
- name: Trivy config scan (Dockerfile + docker-compose)
uses: aquasecurity/trivy-action@0.28.0
continue-on-error: true # report-only; enforcement flipped on in Plan E
with:
scan-type: config
scan-ref: .
format: sarif
output: trivy-config.sarif
- name: Upload Trivy config SARIF
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-config.sarif
category: trivy-config
trivy-license:
name: License compliance scan (Trivy)
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- name: Harden the runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v5
- name: Trivy license scan
uses: aquasecurity/trivy-action@0.28.0
continue-on-error: true # report-only; enforcement flipped on in Plan E
with:
scan-type: fs
scan-ref: .
scanners: license
format: sarif
output: trivy-license.sarif
- name: Upload Trivy license SARIF
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-license.sarif
category: trivy-license
```
- [ ] **Step 2: Validate with actionlint**
Run:
```bash
actionlint .github/workflows/security.yml
```
Expected: no output (exit 0). If actionlint flags an unknown input for an action, check that action's README and correct it. (Note: actionlint does not fetch action inputs, so most such errors are shellcheck/expression issues — fix those.)
- [ ] **Step 3: Commit**
```bash
git add .github/workflows/security.yml
git commit -m "ci(security): add report-only security scan workflow (gitleaks, OSV, Trivy)"
```
---
### Task 3: `codeql.yml` — CodeQL SAST (report-only)
**Files:**
- Create: `.github/workflows/codeql.yml`
**Interfaces:**
- Consumes: repo TypeScript/JavaScript source (analyzed with `build-mode: none`).
- Produces: workflow `CodeQL` with job `analyze`, category `/language:javascript-typescript`. Findings land in the Security tab. Task 6 documents it; Task 5 pins its actions.
- [ ] **Step 1: Write `.github/workflows/codeql.yml`**
```yaml
name: CodeQL
# Static application security testing for all TS/JS. Uses build-mode: none — no
# compile needed, which sidesteps the monorepo/native-module build entirely.
# Default (code-scanning) query suite; security-extended is deferred (triage tax).
# CodeQL uploads alerts to the Security tab but does NOT fail the PR by itself —
# blocking is a repo setting (code-scanning merge protection), documented in the
# maintainer checklist in docs/systems/security-scanning.md.
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '27 3 * * 1' # weekly Monday 03:27 UTC
permissions:
contents: read
concurrency:
group: codeql-${{ github.ref }}
cancel-in-progress: true
jobs:
analyze:
name: Analyze (javascript-typescript)
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # upload SARIF to code scanning
actions: read
steps:
- name: Harden the runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v5
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:javascript-typescript"
```
- [ ] **Step 2: Validate with actionlint**
Run:
```bash
actionlint .github/workflows/codeql.yml
```
Expected: no output (exit 0).
- [ ] **Step 3: Commit**
```bash
git add .github/workflows/codeql.yml
git commit -m "ci(security): add CodeQL SAST workflow (javascript-typescript)"
```
---
### Task 4: `scorecard.yml` — OpenSSF Scorecard (report-only)
**Files:**
- Create: `.github/workflows/scorecard.yml`
**Interfaces:**
- Consumes: the whole repo + workflow metadata (Scorecard evaluates repo posture).
- Produces: workflow `OpenSSF Scorecard` with job `analysis`; publishes results (feeds the public badge added in Plan E) and uploads SARIF. Task 5 pins its actions.
- [ ] **Step 1: Write `.github/workflows/scorecard.yml`**
```yaml
name: OpenSSF Scorecard
# Scores the repo's security posture (branch protection, pinned deps, token
# permissions, etc.) and publishes to the OpenSSF public API so a badge can be
# shown (badge is added in Plan E). REQUIRES the canonical repo to be PUBLIC —
# see the maintainer checklist in docs/systems/security-scanning.md.
on:
branch_protection_rule:
schedule:
- cron: '18 4 * * 2' # weekly Tuesday 04:18 UTC
push:
branches: [main]
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
security-events: write # upload SARIF
id-token: write # publish_results OIDC attestation
steps:
- name: Harden the runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v5
with:
persist-credentials: false
- name: Run Scorecard
uses: ossf/scorecard-action@v2
with:
results_file: results.sarif
results_format: sarif
publish_results: true
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: SARIF file
path: results.sarif
retention-days: 5
- name: Upload SARIF to code scanning
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif
```
- [ ] **Step 2: Validate with actionlint**
Run:
```bash
actionlint .github/workflows/scorecard.yml
```
Expected: no output (exit 0).
- [ ] **Step 3: Commit**
```bash
git add .github/workflows/scorecard.yml
git commit -m "ci(security): add OpenSSF Scorecard workflow"
```
---
### Task 5: Harden existing workflows + SHA-pin every action
**Files:**
- Modify: `.github/workflows/ci.yml` (add harden-runner step)
- Modify: `.github/workflows/release.yml` (add Linux-guarded harden-runner step)
- Modify: `.github/workflows/security.yml`, `codeql.yml`, `scorecard.yml`, `ci.yml`, `release.yml`, `cla.yml`, `deploy-pages.yml`, `docker-publish.yml` (SHA-pin all `uses:`)
**Interfaces:**
- Consumes: all workflow files from Tasks 2-4 plus the four pre-existing ones.
- Produces: every `uses:` pinned to `@<40-char-sha> # vX.Y.Z`; harden-runner (audit) on the two build workflows. No code symbols.
- [ ] **Step 1: Add harden-runner to `ci.yml`**
In `.github/workflows/ci.yml`, insert as the FIRST step of the `build-and-test` job (before `Checkout`):
```yaml
- name: Harden the runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
```
- [ ] **Step 2: Add Linux-guarded harden-runner to `release.yml`**
In `.github/workflows/release.yml`, insert as the FIRST step of the `build` matrix job (before `Checkout`). It MUST be guarded — the matrix includes macOS and Windows, where harden-runner does not run:
```yaml
- name: Harden the runner
if: runner.os == 'Linux'
uses: step-security/harden-runner@v2
with:
egress-policy: audit
```
- [ ] **Step 3: SHA-pin every action across all workflows**
Run `pinact` from the repo root — it rewrites each `uses: owner/repo@vX` to `uses: owner/repo@<sha> # vX` in place:
```bash
pinact run
```
Manual fallback (if `pinact` is unavailable) — resolve each tag to its commit SHA with `gh` and edit by hand. `repos/{repo}/commits/{ref}` dereferences both lightweight and annotated tags to the commit:
```bash
# Example for one action; repeat for every distinct uses: ref.
gh api repos/actions/checkout/commits/v5 --jq '.sha'
# → paste as: uses: actions/checkout@<sha> # v5
```
- [ ] **Step 4: Verify no unpinned action refs remain**
Run (flags any `uses:` ref NOT pinned to a 40-hex-char SHA — catches both `@v5` and non-`v` semver tags like Trivy's `@0.28.0`):
```bash
grep -rnE 'uses: +[^ ]+@' .github/workflows/ | grep -vE '@[0-9a-f]{40}' && echo "UNPINNED REFS FOUND (fix above)" || echo "All actions pinned to SHA"
```
Expected: `All actions pinned to SHA` (the second grep exits non-zero when nothing is unpinned, so the `||` branch prints). A properly pinned line contains `@<40-hex> # vX.Y.Z` and is filtered out; any surviving line is an unpinned ref to fix.
- [ ] **Step 5: Re-validate all workflows**
Run:
```bash
actionlint
```
Expected: no output (exit 0) — actionlint scans every file in `.github/workflows/`.
- [ ] **Step 6: Commit**
```bash
git add .github/workflows/
git commit -m "ci(security): SHA-pin all actions and add harden-runner (audit)"
```
---
### Task 6: Document the pipeline
**Files:**
- Create: `docs/systems/security-scanning.md`
- Modify: `CLAUDE.md` (add a subsystem-table row)
**Interfaces:**
- Consumes: the workflows/config from Tasks 1-5 (documents them).
- Produces: the `security-scanning.md` spec + maintainer checklist referenced by every workflow comment; a CLAUDE.md table row. No code symbols.
- [ ] **Step 1: Write `docs/systems/security-scanning.md`**
```markdown
# Security Scanning & Supply-Chain Assurance
Automated, continuous scanning wired into GitHub Actions. This document is the
reference for what runs, where results go, and the one-time settings a maintainer
must enable. **Current state: report-only** — scanners surface findings in the
Security tab but do not block merges yet. Enforcement (blocking) is turned on in a
later change once the remediation pass has cleared the backlog.
## Workflows
| File | Purpose | Trigger | Result |
|------|---------|---------|--------|
| `.github/dependabot.yml` | Dependency + action + base-image update PRs | weekly | PRs |
| `.github/workflows/codeql.yml` | CodeQL SAST (`javascript-typescript`, build-mode none) | PR + push main + weekly | Security tab |
| `.github/workflows/security.yml` | gitleaks (secrets, full history), OSV-Scanner (deps), Trivy config (IaC), Trivy license | PR + push main + weekly | Security tab |
| `.github/workflows/scorecard.yml` | OpenSSF Scorecard (repo posture) | push main + weekly | Security tab + public badge |
## Tiered policy (target, enforced in a later change)
- **Always block:** gitleaks secret hit; OSV/Trivy fixable HIGH/CRITICAL; Trivy
disallowed license.
- **Advisory (SARIF → Security tab):** CodeQL alerts; OSV/Trivy unfixable or
medium/low; Scorecard.
Code-level gates (OSV, Trivy, gitleaks) block via workflow exit codes. CodeQL
merge-blocking, Dependabot alerts, and native secret-scanning are GitHub *settings*
— see the checklist below.
## Supply-chain hardening
- Every action is pinned to a full commit SHA (`# vX.Y.Z` comment) — resists
tag-move attacks and satisfies Scorecard's Pinned-Dependencies check.
- `step-security/harden-runner` (egress-policy `audit`) on Linux jobs.
- Least-privilege `permissions:` per workflow/job.
- SBOM + SLSA provenance are attached to the published container image (added with
the image-scan work).
## Maintainer checklist (one-time GitHub settings — NOT code)
- [ ] Repository must be **public** (required for the Scorecard badge/publish and
the CodeQL free tier).
- [ ] Settings → Code security: enable **Dependabot alerts** and **Dependabot
security updates**.
- [ ] Settings → Code security: enable **Secret scanning** + **Push protection**.
- [ ] Settings → Code security: enable **CodeQL / code-scanning merge protection**
so high-severity alerts block PRs (the code-level gates do the rest).
- [ ] Branch protection on `main`: require the CI + security status checks to pass.
- [ ] **Manual image bumps:** Dependabot does not track `docker-compose.yml`
`image:` pins — update `caddy` and `livekit/livekit-server` by hand when new
releases ship. (Renovate, which parses compose, is an optional future
alternative.)
```
- [ ] **Step 2: Add the CLAUDE.md subsystem-table row**
In `CLAUDE.md`, inside the "Subsystem Documentation" table (the block of `| File | Contents | Read when... |` rows), add:
```markdown
| [security-scanning.md](docs/systems/security-scanning.md) | CI security pipeline: Dependabot, CodeQL SAST, gitleaks, OSV-Scanner, Trivy (config/image/license), OpenSSF Scorecard, SHA-pinning, harden-runner, tiered enforcement policy, maintainer settings checklist | Any CI security work, adding/changing scanners, enabling enforcement, supply-chain hardening |
```
- [ ] **Step 3: Verify the doc links resolve**
Run:
```bash
test -f docs/systems/security-scanning.md && grep -q 'security-scanning.md' CLAUDE.md && echo "doc + CLAUDE.md row present"
```
Expected: `doc + CLAUDE.md row present`.
- [ ] **Step 4: Commit**
```bash
git add docs/systems/security-scanning.md CLAUDE.md
git commit -m "docs(security): document the scanning pipeline + maintainer checklist"
```
---
### Task 7: Open the PR and verify the pipeline runs
**Files:** none (verification only).
**Interfaces:**
- Consumes: everything from Tasks 1-6, pushed to GitHub.
- Produces: a PR with all scanners running green/advisory — the acceptance gate for Plan A.
- [ ] **Step 1: Push the branch**
Run:
```bash
git push -u origin security-scanning-hardening
```
Expected: branch pushed; GitHub prints a PR-create URL.
- [ ] **Step 2: Open a PR**
Run:
```bash
gh pr create --fill --base main --head security-scanning-hardening \
--title "Security scanning pipeline (report-only)" \
--body "Adds Dependabot, CodeQL, gitleaks, OSV-Scanner, Trivy (config/license), OpenSSF Scorecard, SHA-pinned actions, and harden-runner. All scanners are report-only; enforcement is flipped on in a later change. See docs/systems/security-scanning.md."
```
Expected: prints the PR URL.
- [ ] **Step 3: Watch the checks**
Run:
```bash
gh pr checks --watch
```
Expected: `CI / Build & test` passes; `CodeQL`, `Security` (gitleaks/osv/trivy jobs), and `OpenSSF Scorecard` all complete. Because every scanner is `continue-on-error`/advisory, **no scanner may report a failing (red) required check** — a scanner surfacing findings is fine, but the job itself should not fail the PR. If a job fails for a non-finding reason (bad action input, missing permission), fix the workflow and push.
- [ ] **Step 4: Confirm SARIF + Dependabot registration**
Verify in the GitHub UI (or note as maintainer follow-up if Actions/security features aren't enabled yet):
- Security → Code scanning: alerts appear under categories `codeql`, `osv-scanner`, `trivy-config`, `trivy-license`, and Scorecard.
- Insights → Dependency graph → Dependabot: the three ecosystems (npm, github-actions, docker) are listed as configured.
Run (CLI cross-check of code-scanning analyses, if the repo is public with Actions enabled):
```bash
gh api repos/:owner/:repo/code-scanning/analyses --jq '[.[].category] | unique' 2>/dev/null || echo "code-scanning API not available yet (enable in Settings)"
```
Expected: a list including the scanner categories, or the fallback message (then it's a maintainer-settings follow-up, not a plan defect).
- [ ] **Step 5: Record verification outcome**
No commit. Note in the PR description (or a comment) which checks passed and any settings follow-ups (from the Task 6 maintainer checklist) still pending. Plan A is complete when the PR is green with all four scanners running advisory.
---
## Self-Review Notes
- **Spec coverage (WS1 + supply-chain):** Dependabot (Task 1) ✓; CodeQL (Task 3) ✓; gitleaks + OSV + Trivy config + Trivy license (Task 2) ✓; Scorecard (Task 4) ✓; SHA-pinning + harden-runner (audit) + least-priv permissions across all workflows (Task 5) ✓; docs + maintainer checklist + CLAUDE.md row (Task 6) ✓; report-only sequencing honored throughout (Global Constraints + per-step comments) ✓.
- **Deferred by design (other plans, not gaps):** container image scan + SBOM/provenance + docker-publish restructure → Plan B; helmet/CSP/CORS + DAST → Plan C; Electron hardening → Plan D; remediation of findings + enforcement flip + README badges → Plan E. Task 5's SHA-pin sweep does include `docker-publish.yml` (harmless; Plan B re-pins as it restructures).
- **Enforcement stays OFF here** — every scanner is `continue-on-error`/advisory; no `fail-on`/severity gate is set in this plan. The maintainer-settings toggles (CodeQL merge protection, push protection, branch protection) are documented, not enabled in code.
@@ -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).