Security
Running the scans, reviewing a change, and what to do when a check fails.
Run the checks
| Command | What it checks |
|---|---|
yarn security:secrets --source | Every env var a client reads is listed, with a reason, in scripts/security/client-env.json |
yarn security:secrets --bundles | Built client output holds no key-shaped string and no server secret. Build the clients first |
yarn security:semgrep | The 15 rules in scripts/security/semgrep/bones.yml |
yarn security:semgrep:test | Each 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:zap | OWASP 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.
- List what changed:
git diff origin/main --stat. - Walk the sections that apply — input, database, authorization, rate limits, uploads, rendering, sessions, secrets, dependencies, headers.
- Fix what you find in the same pull request.
- 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 barez.string(). Semgrep fails on one. - Gate it. Build on
protectedProcedureand 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 frombackend/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.jsononly 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.tsorbones.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.mjson purpose.
A risk the team decides to live with goes under Accepted risks in SECURITY_REVIEW.md, with a date.
Related
- Security — what's protected, and what's open.
- Semgrep and ZAP — why these scanners.
- CI/CD — which job runs each check.