From dde45c76a6b56e615ba812f2c73065517b6e008b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 20:13:39 +0200 Subject: [PATCH 1/3] Run CodeQL against the TypeScript tree Static analysis was the one class of check the pipeline had no answer for: lint catches style, the type checker catches shape, the tests catch behaviour, and nothing walked the data flow from an untrusted input to a sink. This is GitHub's default setup written out as a workflow file, so the query suite, the excluded paths and the schedule sit in the diff rather than behind a settings toggle. Pull requests and a weekly run on the default branch. No push trigger: everything reaches main through a pull request that was already analysed, and the weekly run is what keeps the Security tab current. src/generated is excluded. Extracting the 9 MB Prisma client costs minutes and can produce nothing anyone would act on, because the fix for anything found in there is to regenerate it. --- .github/workflows/codeql.yml | 73 ++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..286f9d079 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,73 @@ +name: CodeQL + +# Static analysis for the TypeScript tree. This is GitHub's default setup +# expressed as a workflow file rather than enabled from the repository +# settings, so the query suite, the excluded paths and the schedule are +# reviewable in the diff like everything else here. +# +# Deliberately NOT on `push: main`. The weekly run already analyses the +# default branch, which is what keeps the Security tab current, and every +# change reaches `main` through a pull request that is analysed on the way +# in. Adding a push trigger would analyse the same tree a second time +# minutes later for no additional signal. +on: + pull_request: + branches: [main] + schedule: + # Mondays, 04:27 UTC. Off the hour so the run does not queue behind + # everyone else's midnight cron. + - cron: "27 4 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyse javascript-typescript + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + # Required to write the results into the repository's code-scanning + # alerts. Free for public repositories. + security-events: write + # Required by the CodeQL action to read workflow run metadata. + actions: read + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # `build-mode: none` is the correct mode for JavaScript/TypeScript — + # CodeQL extracts from source and needs neither a compile step nor + # installed dependencies. + # + # The excluded paths are all generated or vendored output. Chief + # among them is `src/generated`, the ~9 MB Prisma client with a + # 224 KB inline schema literal in it: extracting that costs real + # minutes and can produce nothing actionable, because nobody edits + # it and the fix for anything found there is `pnpm prisma generate`. + - name: Initialise CodeQL + uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + with: + languages: javascript-typescript + build-mode: none + config: | + paths-ignore: + - src/generated + - .next + - dist + - coverage + - playwright-report + - test-results + - public + + - name: Analyse + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + with: + category: "/language:javascript-typescript" From 460049bf679a8c97cb5bc996d72499177d4f79aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 20:13:54 +0200 Subject: [PATCH 2/3] Scan the lockfile with dev dependencies and the published image, daily Two questions the existing scans cannot answer. The dependency audit runs with --prod, which is the right scope for a blocking gate but leaves a gap: a dev dependency can still ship. pdfjs-dist is the case that made this concrete. It sits in devDependencies, and rasterize-pdf.ts imports it at runtime, so standalone output tracing copies it into the production image where the prod-scoped audit has never looked. Trivy reads 627 packages out of pnpm-lock.yaml without --include-dev-deps and 1455 with it; pdfjs-dist is only in the second set. Trivy's help text lists the flag as npm/yarn/gradle only, but it works on the pnpm lockfile too, which is worth knowing before someone deletes it as a no-op. The container scan builds the image from the current main. Nobody runs that image. What self-hosters pull is the one built from the last release tag, and an advisory published after the tag lands against an image nothing is about to rebuild. So the second job scans ghcr.io/mbombeck/ healthlog:latest instead of a local build. Schedule only, no push or pull_request trigger, because the change- triggered half is already covered and would just run twice. The lockfile job reports into code scanning without failing: widening to dev dependencies surfaces transitive tool advisories by the dozen, and a red badge from day one gets muted inside a week. The image job fails on CRITICAL, after its own report has been uploaded by a separate step. --- .github/workflows/trivy.yml | 164 ++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 .github/workflows/trivy.yml diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml new file mode 100644 index 000000000..235c8ea63 --- /dev/null +++ b/.github/workflows/trivy.yml @@ -0,0 +1,164 @@ +name: Trivy Scheduled Scan + +# Two scans that the per-change gates cannot do, on a daily cadence. +# +# What already exists and is NOT repeated here: +# - `security.yml` → `dependency-audit` runs `pnpm audit --prod` on every +# push and pull request, blocking on HIGH/CRITICAL. +# - `security.yml` → `docker-security` builds the image on a push to `main` +# and runs `trivy image` against it, blocking on HIGH/CRITICAL. +# +# Both are change-triggered, and both answer "is this change safe". Neither +# answers "is what we shipped last week still safe", because an advisory +# published today lands against an image nobody is about to rebuild. That is +# what the schedule is for. There is deliberately no push or pull_request +# trigger: adding one would run the same scan the `docker-security` job +# already ran, minutes later, on the same tree. +on: + schedule: + # Daily, 05:17 UTC. + - cron: "17 5 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: trivy-scheduled + cancel-in-progress: true + +jobs: + filesystem: + name: Lockfile (including dev dependencies) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + security-events: write + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # `pnpm audit --prod` is the blocking gate and it is scoped to + # production dependencies on purpose: a dev-only advisory should not + # stop a release. The gap that scoping leaves is that "dev dependency" + # and "not in the image" are not the same statement. + # + # `pdfjs-dist` is the worked example. It sits in `devDependencies`, + # and `src/lib/documents/rasterize-pdf.ts` imports it at runtime, so + # Next's standalone output tracing copies it into the production + # image. `pnpm audit --prod` cannot see it. This scan can: without + # the flag Trivy reads 627 packages out of `pnpm-lock.yaml`, with it + # 1455, and `pdfjs-dist` is only in the second set. + # + # Trivy's own `--help` lists the flag as supported for npm, yarn and + # gradle. It works on `pnpm-lock.yaml` too; the help text is behind + # the parser. Verified against this repository before this workflow + # was written, and worth re-checking if the package count ever + # collapses back to the production figure. + # + # Report-only, and that is a deliberate choice rather than a soft + # one. The findings this widening surfaces are overwhelmingly + # transitive dev-tool advisories with fixes available upstream; + # failing the run on them from day one produces a red badge that gets + # muted within a week, and a muted signal is worse than none. The + # SARIF upload puts every finding in the repository's code-scanning + # alerts, which persist, notify, and can be triaged one at a time. + # The blocking answer to "does this ship" stays where it is, in + # `security.yml`. + - name: Trivy filesystem scan + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + env: + # No action input maps to `--include-dev-deps`; Trivy reads every + # flag from a `TRIVY_`-prefixed environment variable, so this is + # the documented way to reach it through the action. + TRIVY_INCLUDE_DEV_DEPS: "true" + with: + scan-type: fs + scan-ref: . + scanners: vuln + format: sarif + output: trivy-filesystem.sarif + severity: "CRITICAL,HIGH,MEDIUM" + limit-severities-for-sarif: true + exit-code: "0" + # Generated Prisma client. Nobody edits it, the fix for anything + # found in it is `pnpm prisma generate`, and it is ~9 MB. + skip-dirs: src/generated + + - name: Upload results + uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + with: + sarif_file: trivy-filesystem.sarif + category: trivy-filesystem + + published-image: + name: Published image + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + packages: read + security-events: write + + steps: + # The `docker-security` job scans an image it just built from the + # current `main`. This one scans the image operators actually pull, + # which is the one built from the last release tag and unchanged + # since. The two answer different questions and the gap between them + # widens every day a release stays out. + # + # Trivy resolves the multi-arch manifest to the runner's platform, so + # this covers `linux/amd64`. The arm64 manifest shares every + # application layer and differs only in native binaries. + - name: Log in to GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # `.trivyignore` lives at the repository root and Trivy reads it + # automatically, so the suppressions the blocking gate already + # carries apply here too and the two scans cannot disagree about + # what is accepted. + - name: Checkout for .trivyignore + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # Fails on CRITICAL only. A critical advisory against the image + # self-hosters are running right now is worth a red badge and a + # notification; HIGH goes to code scanning with everything else, + # because the release pipeline already refuses to publish an image + # carrying one and anything that appears here appeared after the + # fact. + - name: Trivy published-image scan + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: image + image-ref: ghcr.io/mbombeck/healthlog:latest + scanners: vuln + format: sarif + output: trivy-published-image.sarif + severity: "CRITICAL,HIGH,MEDIUM" + limit-severities-for-sarif: true + exit-code: "0" + + - name: Upload results + uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + with: + sarif_file: trivy-published-image.sarif + category: trivy-published-image + + # Separate table pass so the SARIF upload above always runs, even + # when this one fails the job. A scan whose failure suppresses its + # own report is a scan you have to re-run by hand to read. + - name: Fail on a critical finding in the published image + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: image + image-ref: ghcr.io/mbombeck/healthlog:latest + scanners: vuln + format: table + severity: "CRITICAL" + exit-code: "1" From b713ed8c3490d9fb02b29719af8b5457b4d091af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 20:14:04 +0200 Subject: [PATCH 3/3] Score the supply chain against the OpenSSF criteria Most of what Scorecard looks for this repository already does: Actions pinned by commit SHA, a security policy with a private reporting path, Dependabot with auto-merge, keyless signing on the release image, a branch-protected trunk. The value is not the score, it is that the checks keep running after the person who set them up has moved on, and that anyone evaluating the project can read the result without being given CI access. publish_results is on, which is what makes the score citable. It reads repository metadata and the workflow files; nothing about the code or its contents goes anywhere. The token is an OIDC exchange, the same mechanism the release workflow already uses to sign the image. No badge in the README yet. The public API has no entry for this repository until the first run publishes one, and the badge URL currently renders 'invalid repo path'. It goes in once there is a score behind it. --- .github/workflows/scorecard.yml | 60 +++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .github/workflows/scorecard.yml diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 000000000..0fb4bcd82 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,60 @@ +name: OpenSSF Scorecard + +# Automated checks against the OpenSSF's supply-chain criteria: are Actions +# pinned by SHA, is there a security policy, are dependencies updated +# automatically, is the release pipeline signed, is there a fuzzing or a +# static-analysis story. Most of what it looks for this repository already +# does; the value is that it keeps looking after the person who set it up +# has moved on to something else. +# +# `publish_results: true` sends the result to the OpenSSF's public API, +# which is what makes the score citable by anyone evaluating the project +# without asking for CI access. Nothing about the code or its contents +# leaves; the checks read repository metadata and the workflow files. +on: + # Re-score when a branch-protection rule changes, since several checks + # read exactly that. + branch_protection_rule: + schedule: + # Tuesdays, 07:20 UTC. + - cron: "20 7 * * 2" + workflow_dispatch: + +permissions: read-all + +jobs: + analysis: + name: Score supply-chain practices + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + # Write the findings into the repository's code-scanning alerts. + security-events: write + # Publish the result to the public API. This is an OIDC token + # exchange, the same mechanism the release workflow uses for keyless + # image signing; no long-lived credential is involved. + id-token: write + contents: read + actions: read + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Scorecard reads the repository through the API, not through the + # checkout, and leaving the token in `.git/config` would hand it + # to anything else running in the job. + persist-credentials: false + + - name: Run analysis + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: scorecard-results.sarif + results_format: sarif + publish_results: true + + - name: Upload results + uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + with: + sarif_file: scorecard-results.sarif + category: scorecard