Repository navigation
chore: detect schema drift between production and committed migrations - #85
Conversation
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 ReviewWarning This pull request has 1 commit after the last Strix review ( 1 open security finding on this PR:
Review summaryReviewed 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 The change is overall security-conscious: the SQL in Fixed the findings? re-run the review, or tag Updated for Reviewed by Strix |
| - uses: actions/checkout@v4 | ||
| - uses: actions/setup-node@v4 |
There was a problem hiding this comment.
🟢 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.
| - 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.
Why
Migration
0002was merged in March and never applied to production. The Neon workflow that applied migrations on merge is disabled, and it referenced aDATABASE_URLsecret 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/:It reads
pg_catalog, notinformation_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_rorole. Verified against production:pg_indexes,pg_attributedrizzle.__drizzle_migrationsselectfrom routes / nodes /neon_authinsert/delete/create table/drop indexIt'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 theDRIFT_CHECK_DATABASE_URLrepo secret.Also: a migration footgun
drizzle.config.tsloaded.env.localwithoverride: true, soDATABASE_URL=... yarn db:migratewas silently redirected to whatever.env.localpointed at (here, the dev branch) while reporting success. An explicitDATABASE_URLnow wins. This is a plausible origin of the original drift.Verification
✓ production matches the migrated schema (31 columns, 12 indexes, 4 migrations).yarn vitest run: 254/254. Lint clean, no type errors outside tests.