Project audit: grit doctor
grit doctor reads your project and reports the mistakes that do not announce themselves. Every check exists because the mistake it finds was made once, in a real project, and cost something to find.
A build catches a type error. A test catches a wrong answer. Neither catches an encrypted column with no key, a list that was never scoped to its owner, or a database browser whose password is still studio. Those work, which is the problem.
$grit doctor$ 13 checks over 0 resource(s)$ ✓ Nothing to report.
That is a project with nothing to say about it, which is the point: a linter that cries wolf is a linter people turn off. That claim is checked rather than asserted: the docs workflow scaffolds a project on every change to these pages and runs this, which fails if anything is reported.
$grit doctor
Here is the same command on an older project with ten resources:
$grit doctor$ 13 checks over 10 resource(s)$ ✗ Product has encrypted fields, and FIELD_ENCRYPTION_KEY is not set in .env, so they are stored in the clear$ set FIELD_ENCRYPTION_KEY to 32 random bytes (openssl rand -base64 32), or set it in the deployment's environment$ (encryption-key-unset)$ ⚠ /studio still has its default password, and it can browse and edit every table$ set GORM_STUDIO_PASSWORD in .env: openssl rand -hex 16$ (studio-unprotected)$ ⚠ SENTINEL_AUDIT_KEY is not set, so the security audit log's chain is unkeyed$ set SENTINEL_AUDIT_KEY in .env: openssl rand -hex 32$ (default-credentials)$ ⚠ github.com/MUKE-coder/sentinel/v2 is at v2.2.1, below v2.5.0: security fixes from v2.2.2$ run grit upgrade, which raises it$ (framework-library-behind)$ 1 error(s), 5 warning(s)
Every finding names the check it came from, so you can talk about it, and a fix you can act on. The first one there is a real bug from a live test: the column was readable in Postgres and the API had answered 201.
What it checks
| Check | Level | What it means |
|---|---|---|
| encryption-key-unset | error | A model has an encrypted field and FIELD_ENCRYPTION_KEY is not set, so those columns are stored in the clear. Nothing fails: the write succeeds and the value is readable in the database. |
| encrypted-column-in-whitelist | warning | An encrypted column can be searched, sorted or filtered. Ciphertext differs on every write, so the query matches nothing. |
| owned-resource-unscoped | error | A resource with an owner that nothing scopes, or one method that lost its scoping while the others kept it. The list is the path people forget, and an export is the list without pages. |
| owner-settable-from-body | error | The create or update request accepts the owner column, so a caller can file a row under somebody else’s account. |
| resource-could-be-owned | warning | A resource references a user and is scoped to nobody, so every signed-in caller sees every row. Sometimes that is right, which is why it is a question. |
| tenant-shared-resource | warning | The multitenant plugin is installed and a resource has no tenant.Owned, so its rows are shared across organizations. |
| pii-column-not-encrypted | warning | A column named like something that should not be readable in a database dump (an SSN, a card number, a diagnosis) is a plain column. |
| append-only-mutable-routes | error | An append-only resource still mounts PUT, PATCH or DELETE. The model and the database trigger refuse the write, so the endpoint can only fail. |
| studio-unprotected | error / warning | GORM Studio browses and edits every table. An error when it has no login at all, and when its password is still the default in production. |
| default-credentials | error / warning | Dashboard credentials still at their defaults, a JWT secret that is empty, a placeholder or under 32 characters, and an unkeyed security audit chain. |
| framework-library-behind | warning | Sentinel, GORM Studio or Pulse is below the version this CLI ships with, which for Sentinel means missing security fixes. grit upgrade raises them. |
| rate-limits-per-process | warning | Sentinel counts rate limits and lockouts in this process while Redis is configured, so each replica allows a client the full limit again. |
| public-allowlist-sensitive | warning | A --public allowlist publishes a column the generator holds back: a cost, a margin, a stock count, an internal note. |
In CI
grit doctor exits non-zero when anything is an error, and --json prints the report for a machine to read:
- name: Audit the projectrun: grit doctor
$grit doctor --json${$ "Findings": [$ {$ "Level": "error",$ "Check": "encryption-key-unset",$ "Resource": "Product",$ "Message": "has encrypted fields, and FIELD_ENCRYPTION_KEY is not set in .env, so they are stored in the clear",$ "Fix": "set FIELD_ENCRYPTION_KEY to 32 random bytes (openssl rand -base64 32), or set it in the deployment's environment"$ }$ ],$ "Resources": ["Invoice", "Product"],$ "Checks": 13$}
What it reads, and what it does not
It reads your project: the models, handlers, services, routes, .env and go.mod. It connects to nothing, so it is safe to run anywhere and it cannot check what only the database knows.
Two things follow from that. A deployment that sets its secrets outside .env will still be told the keys are missing, because from here they are. And only generated resources are audited: the framework's own tables are skipped, because a session row referencing a user is not a user-owned resource. Asked the loose way, every project reported four framework tables as possibly-owned, including a project with no resources of its own.
Its first finding
Run against Grit's own test projects, the first thing it found was in the scaffold: every project shipped GORM_STUDIO_PASSWORD=studio, a known password on a tool that browses and edits every table, while the Sentinel and Pulse passwords beside it were generated per project. From v3.227.0 the Studio password is generated too, so a new project has nothing to report here, and an older one is told.
