Skip to content

chore: detect schema drift between production and committed migrations - #85

Merged
Skeyelab merged 2 commits into
masterfrom
chore/schema-drift-check
Sep 22, 2026
Merged

Skeyelab merged 2 commits into
masterfrom
chore/schema-drift-check

Conversation

@Skeyelab

Copy link
Copy Markdown
Member

Why

Migration 0002 was merged in March and never applied to production. The Neon workflow that applied migrations on merge is disabled, and it referenced a DATABASE_URL secret that was never set. Nobody noticed for six months; I fixed production by hand today.

What this adds

A workflow that runs weekly and on pushes touching db/:

  1. Applies every committed migration to a throwaway Postgres 17 in CI. That's the expected schema.
  2. Reads production's columns, indexes, and recorded migration hashes.
  3. Fails on any difference, including a migration that's committed but not applied.

It reads pg_catalog, not information_schema: the latter filters rows by privilege, so a least-privilege role would see an empty schema and report everything as drift.

Access

Production is read through a new drift_ro role. Verified against production:

attempt result
read pg_indexes, pg_attribute allowed (needed)
read drizzle.__drizzle_migrations allowed (needed)
select from routes / nodes / neon_auth denied
insert / delete / create table / drop index denied

It's created in SQL on purpose: roles created through the Neon API are automatically members of neon_superuser, and that membership can't be revoked, so an API-created role can read and write everything. The connection string is the DRIFT_CHECK_DATABASE_URL repo secret.

Also: a migration footgun

drizzle.config.ts loaded .env.local with override: true, so DATABASE_URL=... yarn db:migrate was silently redirected to whatever .env.local pointed at (here, the dev branch) while reporting success. An explicit DATABASE_URL now wins. This is a plausible origin of the original drift.

Verification

  • 7 unit tests for the comparison (missing/unexpected/changed columns and indexes, unapplied and unknown migrations), written first.
  • Rehearsed locally against production with a Docker Postgres: reports ✓ production matches the migrated schema (31 columns, 12 indexes, 4 migrations).
  • Then broke the local copy on purpose (dropped the per-user index, restored the global one, removed a migration row) and confirmed it reported 3 differences and exited 1.
  • yarn vitest run: 254/254. Lint clean, no type errors outside tests.

Migration 0002 was merged in March and never reached production: the Neon PR
workflow that applied migrations is disabled (and referenced a DATABASE_URL
secret that was never set), so the global route-slug index survived for six
months until it was fixed by hand today.

Weekly (and on pushes touching db/), CI now rebuilds the schema from the
committed migrations in a throwaway Postgres and compares it to production:
columns, indexes, and the recorded migration hashes. It fails on any
difference, including a migration that is committed but never applied.

Production is read through a new least-privilege `drift_ro` Neon role that can
read pg_catalog and drizzle.__drizzle_migrations and nothing else: it is denied
on every application table, on neon_auth, and on DDL. (Roles created through
the Neon API are auto-granted neon_superuser and cannot be restricted, so this
role is created in SQL.) Its URL is the DRIFT_CHECK_DATABASE_URL repo secret.

drizzle.config.ts: an explicit DATABASE_URL now wins over .env.local. It used
to be overridden silently, so `DATABASE_URL=... yarn db:migrate` would migrate
whatever .env.local pointed at (the dev branch) while appearing to succeed —
a plausible origin of the original drift.
@strix-security

strix-security Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Strix Security Review

Warning

This pull request has 1 commit after the last Strix review (705ff00). Strix has not reviewed these changes.
Automatic review on push is off for this repository. To review the latest changes, tag @strix-security in a comment, or turn on re-review on push.

1 open security finding on this PR:

Review summary

Reviewed all seven changed files in PR #85, which introduces a weekly/on-push schema drift check that rebuilds the expected schema from committed migrations in a throwaway Postgres and compares it read-only against production via a new least-privilege drift_ro role.

The change is overall security-conscious: the SQL in collect.ts is fully static (no string interpolation, so no injection surface), the workflow has no pull_request trigger (so untrusted code cannot obtain the production credential), the drift_ro role is read-only with SELECT denied on application tables, and the drizzle.config.ts change is a security-positive fix that prevents silent migration redirection. The one confirmed finding is "Unpinned third-party GitHub Actions in schema-drift workflow expose production database credential" — the new workflow injects secrets.DRIFT_CHECK_DATABASE_URL into a job that uses mutable actions/checkout@v4 and actions/setup-node@v4 tag references rather than pinned commit SHAs, a low-severity supply-chain hardening gap.

Fixed the findings? re-run the review, or tag @strix-security in a PR comment to run a fresh review.

Updated for 705ff00.


Reviewed by Strix
Re-run review · Configure security review settings

@strix-security strix-security Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strix flagged a new security finding below. See the pinned summary comment for the full PR status.

Comment thread .github/workflows/schema-drift.yml Outdated
Comment on lines +31 to +32
- uses: actions/checkout@v4
- uses: actions/setup-node@v4

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Unpinned third-party GitHub Actions in schema-drift workflow expose production database credential

Severity: LOW · CWE-829

The new schema-drift workflow references actions/checkout@v4 and actions/setup-node@v4 by mutable tag rather than a pinned commit SHA. The same job later injects the production credential secrets.DRIFT_CHECK_DATABASE_URL into the environment of the yarn db:drift step. If the v4 tag of either action were repointed (a compromised maintainer account or registry, or an upstream supply-chain compromise), the action would execute arbitrary code in this job and could tamper with the checkout contents or the installed Node/Yarn toolchain so that the subsequent yarn db:drift step exfiltrates the credential as it runs. Impact is bounded: the drift_ro role is read-only and SELECT on application tables (routes, nodes, neon_auth) is denied, exposing only schema metadata (pg_catalog, pg_indexes, pg_attribute) and migration hashes. The trigger surface is also constrained (push to master, weekly schedule, and workflow_dispatch — no pull_request trigger), and the repository's pre-existing ci.yml and neon_workflow.yml use the same tag-pinned practice. This is therefore a hardening gap consistent with existing practice rather than a new escalation, but the new workflow is the first to read a production credential of any kind through tag-pinned actions.

Suggested change
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
Prompt to fix with AI
This is a security vulnerability found during a code review.

Vulnerability: Unpinned third-party GitHub Actions in schema-drift workflow expose production database credential
Severity: LOW
CWE: CWE-829

The new schema-drift workflow references `actions/checkout@v4` and `actions/setup-node@v4` by mutable tag rather than a pinned commit SHA. The same job later injects the production credential `secrets.DRIFT_CHECK_DATABASE_URL` into the environment of the `yarn db:drift` step. If the `v4` tag of either action were repointed (a compromised maintainer account or registry, or an upstream supply-chain compromise), the action would execute arbitrary code in this job and could tamper with the checkout contents or the installed Node/Yarn toolchain so that the subsequent `yarn db:drift` step exfiltrates the credential as it runs. Impact is bounded: the `drift_ro` role is read-only and `SELECT` on application tables (`routes`, `nodes`, `neon_auth`) is denied, exposing only schema metadata (`pg_catalog`, `pg_indexes`, `pg_attribute`) and migration hashes. The trigger surface is also constrained (push to `master`, weekly schedule, and `workflow_dispatch` — no `pull_request` trigger), and the repository's pre-existing `ci.yml` and `neon_workflow.yml` use the same tag-pinned practice. This is therefore a hardening gap consistent with existing practice rather than a new escalation, but the new workflow is the first to read a production credential of any kind through tag-pinned actions.

Location: .github/workflows/schema-drift.yml:31-32
Context: Pin third-party actions to commit SHAs
```
// Before:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
// After:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
```

How to fix:
Pin all third-party actions in the workflow to full 40-character commit SHAs after reviewing them, replacing the mutable tags. Keep the pins updated when bumping dependency versions (e.g. via Dependabot or a tool such as StepSecurity's harden-runner, which can also alert on out-of-policy actions). Consider adding an explicit `permissions:` block applying least-privilege defaults so the job's `GITHUB_TOKEN` cannot be abused by a compromised action in addition to the credentials already in scope.

Please fix this vulnerability. If you propose a fix, make it concise and minimal.

React 👍 / 👎 to tune Strix for this repo. A repo collaborator (or the PR author) can resolve this thread to dismiss the finding.

Strix flagged that this job receives the production DRIFT_CHECK_DATABASE_URL
secret while using mutable v4 tags. Pin both actions to the commit SHAs behind
v4.4.0 so a retagged release cannot reach the credential.
@Skeyelab
Skeyelab merged commit fffa32a into master Sep 22, 2026
2 checks passed
@Skeyelab
Skeyelab deleted the chore/schema-drift-check branch September 22, 2026 18:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant