Merge pull request #27 from TheZwiss/docs/security-design-record
docs: complete security design record on main (spec + Plan A plan)
This commit is contained in:
@@ -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).
|
||||
Reference in New Issue
Block a user