CI static analysis: typescript-eslint strict + Semgrep (#15) #41

Merged
james merged 2 commits from 15-static-analysis into main 2026-06-14 13:16:31 +00:00
Owner

Closes #15.

Summary

  • typescript-eslint strict ruleset on top of next/typescript, layered before prettier. Stays on strict (not strictTypeChecked) so lint doesn't re-run the type-checker — that's the dedicated typecheck job's job.
  • New static-analysis Forgejo Actions job that runs Semgrep against curated registry packs (p/javascript, p/typescript, p/nodejsscan, p/owasp-top-ten) plus a local .semgrep/ pack of dangerous-API bans.
  • The local pack flags eval, new Function, string-form setTimeout/setInterval, sql.raw with interpolated/concatenated strings, and child_process.exec* with interpolation/concatenation. These are unconditional — there is no legitimate use of any of them in this codebase.
  • ESLint override under tests/** to allow non-null assertions — in tests, value! after a known-good setup step is clearer than if (!value) throw and a bad assertion fails the test with a useful error.

Why a local Semgrep pack on top of the registry packs

While building this I confirmed empirically that Semgrep's curated detect-eval-with-expression and friends are taint rules — they only fire when the sink's input flows from a recognised browser source (location.href, URLSearchParams). A bare eval(req.body.code) in a Next.js route handler does not match. The registry packs are excellent at flow-shaped OWASP patterns but are not a dangerous-API ban tool, so the ticket's acceptance criteria (plant eval, get a finding) needed the local pack. Full rationale in ADR-0005.

Acceptance criteria

  • A planted unsafe pattern (e.g. eval, raw-string SQL concatenation) trips the check in a throwaway branch. Verified locally with a scratch file containing eval(userInput), sql.raw("..." + userId), execSync("ls " + input), and new Function(src) — all four trip the local Semgrep pack. Full-repo scan over current code is clean (0 findings).
  • Findings are surfaced in the PR output clearly. Semgrep renders findings as a file/line/snippet/message tree in the workflow log, which is what shows up on the PR check page.

Test plan

  • PR pipeline runs all jobs (Lint, Typecheck, Static analysis (Semgrep), Build, Test (sqlite), Test (postgres)) and they all pass.
  • Open a throwaway PR with eval(userInput) in a real source file and confirm the Static analysis job fails with a clear message.

🤖 Generated with Claude Code

Closes #15. ## Summary - typescript-eslint `strict` ruleset on top of `next/typescript`, layered before `prettier`. Stays on `strict` (not `strictTypeChecked`) so lint doesn't re-run the type-checker — that's the dedicated `typecheck` job's job. - New `static-analysis` Forgejo Actions job that runs Semgrep against curated registry packs (`p/javascript`, `p/typescript`, `p/nodejsscan`, `p/owasp-top-ten`) **plus** a local `.semgrep/` pack of dangerous-API bans. - The local pack flags `eval`, `new Function`, string-form `setTimeout`/`setInterval`, `sql.raw` with interpolated/concatenated strings, and `child_process.exec*` with interpolation/concatenation. These are unconditional — there is no legitimate use of any of them in this codebase. - ESLint override under `tests/**` to allow non-null assertions — in tests, `value!` after a known-good setup step is clearer than `if (!value) throw` and a bad assertion fails the test with a useful error. ## Why a local Semgrep pack on top of the registry packs While building this I confirmed empirically that Semgrep's curated `detect-eval-with-expression` and friends are **taint** rules — they only fire when the sink's input flows from a recognised browser source (`location.href`, `URLSearchParams`). A bare `eval(req.body.code)` in a Next.js route handler does not match. The registry packs are excellent at flow-shaped OWASP patterns but are not a dangerous-API ban tool, so the ticket's acceptance criteria (plant `eval`, get a finding) needed the local pack. Full rationale in [ADR-0005](docs/adr/0005-static-analysis-stack.md). ## Acceptance criteria - [x] A planted unsafe pattern (e.g. `eval`, raw-string SQL concatenation) trips the check in a throwaway branch. Verified locally with a scratch file containing `eval(userInput)`, `sql.raw("..." + userId)`, `execSync("ls " + input)`, and `new Function(src)` — all four trip the local Semgrep pack. Full-repo scan over current code is clean (0 findings). - [x] Findings are surfaced in the PR output clearly. Semgrep renders findings as a file/line/snippet/message tree in the workflow log, which is what shows up on the PR check page. ## Test plan - [ ] PR pipeline runs all jobs (Lint, Typecheck, Static analysis (Semgrep), Build, Test (sqlite), Test (postgres)) and they all pass. - [ ] Open a throwaway PR with `eval(userInput)` in a real source file and confirm the Static analysis job fails with a clear message. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
CI static analysis: typescript-eslint strict + Semgrep (#15)
Some checks failed
PR / Static analysis (Semgrep) (pull_request) Failing after 17s
PR / Typecheck (pull_request) Successful in 1m7s
PR / Test (sqlite) (pull_request) Successful in 1m34s
PR / Test (postgres) (pull_request) Successful in 2m5s
PR / Build (pull_request) Successful in 2m44s
PR / Lint (pull_request) Successful in 3m10s
76ddae8754
Layers a deeper static-analysis pass on top of the lint/typecheck jobs
from #13:

- typescript-eslint `strict` ruleset on top of next/typescript, layered
  before prettier. Stays on `strict` (not `strictTypeChecked`) to avoid
  re-running the type-checker during lint — see ADR-0005.
- A `static-analysis` Forgejo Actions job that runs Semgrep against
  curated registry packs (p/javascript, p/typescript, p/nodejsscan,
  p/owasp-top-ten) plus a local `.semgrep/` pack of dangerous-API bans
  (eval, Function constructor, string-form setTimeout/setInterval,
  sql.raw with interpolated strings, child_process.exec* with
  interpolation). The registry packs cover taint-shaped vuln patterns;
  the local pack covers unconditional dangerous-API uses that the
  registry's taint rules don't fire on without a recognised browser
  source.
- ESLint override under `tests/**` to allow non-null assertions — in
  tests, `value!` after a known-good setup step is clearer than `if
  (!value) throw` and a bad assertion fails the test with a useful
  error.

Verified locally against planted unsafe patterns (eval, raw-string SQL
concatenation in sql.raw, execSync with concatenation, new Function):
all four trip the local Semgrep pack. Full-repo scan over current code
is clean.

ADR-0005 records why this shape (strict over strictTypeChecked,
registry+local Semgrep stack, Forgejo-compatible offline runner) was
chosen over the alternatives considered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Run Semgrep in the standard runner image, pip-install the CLI
All checks were successful
PR / Static analysis (Semgrep) (pull_request) Successful in 31s
PR / Lint (pull_request) Successful in 1m16s
PR / Typecheck (pull_request) Successful in 1m25s
PR / Test (sqlite) (pull_request) Successful in 1m33s
PR / Test (postgres) (pull_request) Successful in 1m35s
PR / Build (pull_request) Successful in 1m48s
8de3633863
The dedicated `semgrep/semgrep` container image is minimal and omits
packages Forgejo's actions/checkout needs at runtime, so it isn't
usable as a job container here. Switch the static-analysis job to the
same `ghcr.io/catthehacker/ubuntu:js-24.04` image the other jobs use
and `pip install semgrep==1.94.0` in a setup step. Net result is a
uniform runner environment and the same pinned CLI behaviour, at the
cost of a one-time pip install per run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
james force-pushed 15-static-analysis from 8de3633863
All checks were successful
PR / Static analysis (Semgrep) (pull_request) Successful in 31s
PR / Lint (pull_request) Successful in 1m16s
PR / Typecheck (pull_request) Successful in 1m25s
PR / Test (sqlite) (pull_request) Successful in 1m33s
PR / Test (postgres) (pull_request) Successful in 1m35s
PR / Build (pull_request) Successful in 1m48s
to 8c95cf40ec
All checks were successful
PR / Test (postgres) (pull_request) Successful in 56s
PR / Trivy (image) (pull_request) Successful in 1m30s
PR / Build (pull_request) Successful in 1m5s
PR / Test (sqlite) (pull_request) Successful in 16m39s
PR / OSV-Scanner (pull_request) Successful in 37s
PR / Static analysis (Semgrep) (pull_request) Successful in 39s
PR / Typecheck (pull_request) Successful in 49s
PR / Lint (pull_request) Successful in 50s
PR / npm audit (pull_request) Successful in 53s
2026-06-14 13:15:47 +00:00
Compare
james merged commit 31289b74ef into main 2026-06-14 13:16:31 +00:00
Sign in to join this conversation.
No description provided.