Quality

Security

Running the scans, reviewing a change, and what to do when a check fails.

Run the checks

CommandWhat it checks
yarn security:secrets --sourceEvery env var a client reads is listed, with a reason, in scripts/security/client-env.json
yarn security:secrets --bundlesBuilt client output holds no key-shaped string and no server secret. Build the clients first
yarn security:semgrepThe 15 rules in scripts/security/semgrep/bones.yml
yarn security:semgrep:testEach rule still flags its bad examples and passes its good ones
yarn security:csp <surface>...Every page loads under its CSP with no violation. Surfaces are blog, docs, web-static, desktop, web-app, and web-app-signed-in
yarn security:zapOWASP ZAP against the running web app and backend. Reports land in zap-report/

Semgrep and ZAP run in Docker.

ZAP and the web-app CSP checks need the stack running: the backend on port 3000, and the web app's preview server on 3003.

yarn workspace web-app preview --port 3003 --host
ZAP_HOST=host.docker.internal yarn security:zap

On macOS, Docker can't reach the host's localhost, hence --host and ZAP_HOST. The signed-in CSP check also needs Mailpit and a fresh database, since only the first user to sign up is an owner.

Review a change

SECURITY_REVIEW.md in the repo root is the checklist. Walk it for any pull request that touches an input: a procedure, a raw route, an auth hook, a query, an upload, a form, or a page that renders user content.

  1. List what changed: git diff origin/main --stat.
  2. Walk the sections that apply — input, database, authorization, rate limits, uploads, rendering, sessions, secrets, dependencies, headers.
  3. Fix what you find in the same pull request.
  4. Add a Security review line to the pull request description: the sections checked, and what you found.

Write a safe procedure

When you add a procedure:

  • Bound every input. Use the helpers in backend/src/input-limits.ts — idString, nameString, titleString, shortText, searchString — not a bare z.string(). Semgrep fails on one.
  • Gate it. Build on protectedProcedure and add a permission check. See Checking permissions.
  • Scope every query to the organization or user the permission check used, not just a row id from input.
  • Limit anything costly. LLM calls, email, search, and upload URLs use .use(rateLimited(...)) with a limiter from backend/src/lib/rate-limit.ts.
  • Test a rejection. At least one test calls it as someone who must be turned away.

A new limit goes into all three copies of input-limits.ts — backend, web-app, and desktop — and the matching form field sets maxLength.

Headers and CSP

scripts/security/headers.mjs defines every frontend's headers and Content Security Policy. Change them there, not in an app's config.

A new third-party origin — a font host, a CDN, an analytics script — fails yarn security:csp until it's added to the policy. That's on purpose.

Don't add an inline <script> to an index.html. Put it in public/ and load it with src.

When a check fails

  • Client env var. Add it to client-env.json only if its value is safe for anyone to read.
  • Semgrep. Fix the code. If the rule is wrong, fix the rule and add the case to bones.ts or bones.tsx.
  • ZAP. Fix the finding. If a rule can't apply here, lower it in scripts/security/zap/*.conf, with the reason on the same line.
  • CSP. Fix the page, or add the origin to headers.mjs on purpose.

A risk the team decides to live with goes under Accepted risks in SECURITY_REVIEW.md, with a date.