prisma-guard-lite 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +217 -0
- package/VALIDATION.md +53 -0
- package/dist/index.js +120 -0
- package/dist/report.js +65 -0
- package/dist/scanner.js +199 -0
- package/dist/schemaParser.js +163 -0
- package/dist/sqlChecks.js +98 -0
- package/examples/github-action.yml +18 -0
- package/examples/reports/example-clean-report.md +32 -0
- package/examples/reports/example-high-risk-report.md +52 -0
- package/package.json +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cullen Meyers
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# Prisma Guard Lite
|
|
2
|
+
|
|
3
|
+
Prisma Guard Lite is a pre-deploy migration risk checker for Prisma projects.
|
|
4
|
+
|
|
5
|
+
It scans Prisma migration SQL for destructive and deployment-sensitive
|
|
6
|
+
operations, then produces clear terminal, Markdown, or JSON results for local
|
|
7
|
+
review and CI.
|
|
8
|
+
|
|
9
|
+
## Why this exists
|
|
10
|
+
|
|
11
|
+
Prisma migrations can contain operations that deserve deliberate review before
|
|
12
|
+
deployment: dropped columns, dropped tables, unbounded deletes, truncation, and
|
|
13
|
+
column type conversions. These statements can be easy to miss inside generated
|
|
14
|
+
SQL.
|
|
15
|
+
|
|
16
|
+
Prisma Guard Lite makes those risks visible without connecting to your database.
|
|
17
|
+
It works locally, has no runtime dependencies, and can focus on migrations
|
|
18
|
+
introduced by a pull request or deployment.
|
|
19
|
+
|
|
20
|
+
## Quick start
|
|
21
|
+
|
|
22
|
+
Requires Node.js 18 or newer.
|
|
23
|
+
|
|
24
|
+
Scan the latest migration:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx prisma-guard-lite --latest
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Scan migrations changed since the main branch:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npx prisma-guard-lite --since main
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Check staged migrations and fail when a high-severity risk is found:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npx prisma-guard-lite --staged --fail-on high
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Pass a project directory to scan somewhere other than the current directory:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx prisma-guard-lite /path/to/project --latest
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Unless `--no-write` is provided, the command writes
|
|
49
|
+
`prisma-guard-report.md` in the scanned project root.
|
|
50
|
+
|
|
51
|
+
## What it checks
|
|
52
|
+
|
|
53
|
+
High severity:
|
|
54
|
+
|
|
55
|
+
- `DROP TABLE`
|
|
56
|
+
- `DROP COLUMN`
|
|
57
|
+
- `TRUNCATE`
|
|
58
|
+
- `DELETE FROM` without a `WHERE` clause
|
|
59
|
+
- risky `ALTER TABLE ... ALTER COLUMN ... TYPE` operations
|
|
60
|
+
|
|
61
|
+
Medium severity:
|
|
62
|
+
|
|
63
|
+
- `CREATE EXTENSION`
|
|
64
|
+
- `DROP EXTENSION`
|
|
65
|
+
- Prisma `Unsupported(...)`
|
|
66
|
+
- Prisma `dbgenerated(...)`
|
|
67
|
+
- soft deletion combined with `@unique`
|
|
68
|
+
- tenant-like fields without a matching `@@index`
|
|
69
|
+
|
|
70
|
+
Noisy best-practice checks are disabled by default. `--include-low` enables
|
|
71
|
+
checks for missing timestamp fields and simple tenant-ownership heuristics.
|
|
72
|
+
|
|
73
|
+
## Scan modes
|
|
74
|
+
|
|
75
|
+
Only one scan mode may be used at a time.
|
|
76
|
+
|
|
77
|
+
| Command | Migration files scanned |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| `npx prisma-guard-lite` | All migrations, labeled as a history scan |
|
|
80
|
+
| `npx prisma-guard-lite --latest` | The newest migration folder |
|
|
81
|
+
| `npx prisma-guard-lite --since main` | Migration files changed since the provided Git ref |
|
|
82
|
+
| `npx prisma-guard-lite --staged` | Staged migration files |
|
|
83
|
+
| `npx prisma-guard-lite --changed` | Changed and untracked migration files |
|
|
84
|
+
|
|
85
|
+
Git-aware modes require a Git worktree.
|
|
86
|
+
|
|
87
|
+
## Options
|
|
88
|
+
|
|
89
|
+
| Flag | Behavior |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| `--json` | Print structured JSON |
|
|
92
|
+
| `--include-low` | Include noisy low-severity best-practice checks |
|
|
93
|
+
| `--no-write` | Do not write `prisma-guard-report.md` |
|
|
94
|
+
| `--fail-on high` | Exit with code 1 when high findings exist |
|
|
95
|
+
| `--fail-on medium` | Exit with code 1 when high or medium findings exist |
|
|
96
|
+
|
|
97
|
+
## Example output
|
|
98
|
+
|
|
99
|
+
```text
|
|
100
|
+
Prisma Guard Lite
|
|
101
|
+
|
|
102
|
+
Scan mode: latest migration
|
|
103
|
+
Files scanned: 1 migration file
|
|
104
|
+
Summary: 5 high, 6 medium, 0 low
|
|
105
|
+
|
|
106
|
+
HIGH (5)
|
|
107
|
+
1. Migration changes a column type
|
|
108
|
+
prisma/migrations/20260101000000_init/migration.sql:3
|
|
109
|
+
Changing a column type can rewrite or lock a table and may fail when existing values cannot be converted.
|
|
110
|
+
Suggested fix: Test the conversion on production-like data and consider a staged add, backfill, and swap migration.
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
See the complete [high-risk report](examples/reports/example-high-risk-report.md)
|
|
114
|
+
and [clean report](examples/reports/example-clean-report.md).
|
|
115
|
+
|
|
116
|
+
## JSON output
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
npx prisma-guard-lite --latest --json --no-write
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{
|
|
124
|
+
"scanMode": "latest migration",
|
|
125
|
+
"filesScanned": 1,
|
|
126
|
+
"summary": {
|
|
127
|
+
"high": 1,
|
|
128
|
+
"medium": 0,
|
|
129
|
+
"low": 0
|
|
130
|
+
},
|
|
131
|
+
"findings": [
|
|
132
|
+
{
|
|
133
|
+
"severity": "high",
|
|
134
|
+
"file": "prisma/migrations/20260623090000_remove_legacy/migration.sql",
|
|
135
|
+
"line": 4,
|
|
136
|
+
"title": "Migration drops a column",
|
|
137
|
+
"explanation": "Dropping a column permanently removes its stored data and may break older application versions.",
|
|
138
|
+
"suggestedFix": "Stop reading and writing the column first, deploy that change, then remove the column in a later migration."
|
|
139
|
+
}
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## GitHub Actions
|
|
145
|
+
|
|
146
|
+
This workflow checks migration files changed since `origin/main` and fails the
|
|
147
|
+
job when a high-severity finding exists:
|
|
148
|
+
|
|
149
|
+
```yaml
|
|
150
|
+
name: Prisma migration guard
|
|
151
|
+
|
|
152
|
+
on:
|
|
153
|
+
pull_request:
|
|
154
|
+
|
|
155
|
+
jobs:
|
|
156
|
+
prisma-guard:
|
|
157
|
+
runs-on: ubuntu-latest
|
|
158
|
+
steps:
|
|
159
|
+
- uses: actions/checkout@v4
|
|
160
|
+
with:
|
|
161
|
+
fetch-depth: 0
|
|
162
|
+
- uses: actions/setup-node@v4
|
|
163
|
+
with:
|
|
164
|
+
node-version: 22
|
|
165
|
+
cache: npm
|
|
166
|
+
- run: npm install
|
|
167
|
+
- run: npx prisma-guard-lite --since origin/main --fail-on high
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
A copy-ready version is available at
|
|
171
|
+
[examples/github-action.yml](examples/github-action.yml).
|
|
172
|
+
|
|
173
|
+
## Local development
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
npm install
|
|
177
|
+
npm run build
|
|
178
|
+
node dist/index.js example --latest
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
During development:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
npm run dev -- example --latest
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Local verification
|
|
188
|
+
|
|
189
|
+
Run the release checks:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
npm run build
|
|
193
|
+
node dist/index.js example --latest
|
|
194
|
+
node dist/index.js example --json
|
|
195
|
+
node dist/index.js example --latest --fail-on high
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Expected results for the included example:
|
|
199
|
+
|
|
200
|
+
- the build exits `0`
|
|
201
|
+
- `--latest` exits `0` and reports 5 high, 6 medium, and 0 low findings
|
|
202
|
+
- `--json` exits `0` and returns `scanMode`, `filesScanned`, `summary`, and
|
|
203
|
+
`findings`
|
|
204
|
+
- `--latest --fail-on high` prints the report and exits `1`
|
|
205
|
+
|
|
206
|
+
## Validation
|
|
207
|
+
|
|
208
|
+
The rules were tested against seven public Prisma projects containing 145
|
|
209
|
+
migrations. The validation led to focused Git-based scan modes and disabling
|
|
210
|
+
noisy schema conventions by default. See [VALIDATION.md](VALIDATION.md).
|
|
211
|
+
|
|
212
|
+
## Disclaimer
|
|
213
|
+
|
|
214
|
+
This is a heuristic scanner. It does not guarantee database safety.
|
|
215
|
+
|
|
216
|
+
Always review migrations, test against production-like data, maintain backups,
|
|
217
|
+
and prepare a rollback or recovery plan before deployment.
|
package/VALIDATION.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Validation
|
|
2
|
+
|
|
3
|
+
Prisma Guard Lite was tested against seven public Prisma projects containing
|
|
4
|
+
145 committed migration files.
|
|
5
|
+
|
|
6
|
+
## What the first validation found
|
|
7
|
+
|
|
8
|
+
The original scanner reviewed every migration in repository history. It found
|
|
9
|
+
real destructive operations, but the whole-history approach produced too much
|
|
10
|
+
noise for pull-request and deployment review. Old, already-applied migrations
|
|
11
|
+
appeared beside newly introduced changes and could dominate the report.
|
|
12
|
+
|
|
13
|
+
## What changed in v2
|
|
14
|
+
|
|
15
|
+
The checker now supports focused migration scopes:
|
|
16
|
+
|
|
17
|
+
- `--latest` for the newest migration
|
|
18
|
+
- `--since <git-ref>` for branch and pull-request review
|
|
19
|
+
- `--staged` for pre-commit review
|
|
20
|
+
- `--changed` for local working-tree review
|
|
21
|
+
|
|
22
|
+
The default remains a clearly labeled history scan for broad audits.
|
|
23
|
+
|
|
24
|
+
## Most valuable findings
|
|
25
|
+
|
|
26
|
+
The rules that produced the clearest deployment signals were:
|
|
27
|
+
|
|
28
|
+
- `DROP COLUMN`
|
|
29
|
+
- `DROP TABLE`
|
|
30
|
+
- `TRUNCATE`
|
|
31
|
+
- `DELETE FROM` without `WHERE`
|
|
32
|
+
- risky `ALTER COLUMN TYPE` operations
|
|
33
|
+
|
|
34
|
+
The type-change matcher was also tightened after validation exposed false
|
|
35
|
+
positives involving a column literally named `type`.
|
|
36
|
+
|
|
37
|
+
## Noise reduction
|
|
38
|
+
|
|
39
|
+
The following best-practice checks are disabled by default:
|
|
40
|
+
|
|
41
|
+
- missing `createdAt`
|
|
42
|
+
- missing `updatedAt`
|
|
43
|
+
- missing `deletedAt`
|
|
44
|
+
- tenant ownership guessed from model names
|
|
45
|
+
|
|
46
|
+
They remain available through `--include-low`, but do not dilute the default
|
|
47
|
+
pre-deploy risk report.
|
|
48
|
+
|
|
49
|
+
## Current verdict
|
|
50
|
+
|
|
51
|
+
The useful core is a focused Prisma migration risk checker, not a general schema
|
|
52
|
+
style linter. Public validation should now concentrate on whether findings from
|
|
53
|
+
`--since`, `--staged`, and `--changed` are actionable in real pull requests.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
const node_fs_1 = require("node:fs");
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const report_js_1 = require("./report.js");
|
|
10
|
+
const scanner_js_1 = require("./scanner.js");
|
|
11
|
+
function parseArgs(args) {
|
|
12
|
+
let projectPath;
|
|
13
|
+
let json = false;
|
|
14
|
+
let mode = "history";
|
|
15
|
+
let sinceRef;
|
|
16
|
+
let includeLow = false;
|
|
17
|
+
let writeReport = true;
|
|
18
|
+
let failOn;
|
|
19
|
+
let selectedMode;
|
|
20
|
+
const selectMode = (nextMode, flag) => {
|
|
21
|
+
if (selectedMode) {
|
|
22
|
+
throw new Error(`${flag} cannot be combined with ${selectedMode}.`);
|
|
23
|
+
}
|
|
24
|
+
mode = nextMode;
|
|
25
|
+
selectedMode = flag;
|
|
26
|
+
};
|
|
27
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
28
|
+
const arg = args[index];
|
|
29
|
+
if (arg === "--json") {
|
|
30
|
+
json = true;
|
|
31
|
+
}
|
|
32
|
+
else if (arg === "--latest") {
|
|
33
|
+
selectMode("latest", arg);
|
|
34
|
+
}
|
|
35
|
+
else if (arg === "--staged") {
|
|
36
|
+
selectMode("staged", arg);
|
|
37
|
+
}
|
|
38
|
+
else if (arg === "--changed") {
|
|
39
|
+
selectMode("changed", arg);
|
|
40
|
+
}
|
|
41
|
+
else if (arg === "--since") {
|
|
42
|
+
selectMode("since", arg);
|
|
43
|
+
sinceRef = args[index + 1];
|
|
44
|
+
if (!sinceRef || sinceRef.startsWith("-")) {
|
|
45
|
+
throw new Error("--since requires a Git ref.");
|
|
46
|
+
}
|
|
47
|
+
index += 1;
|
|
48
|
+
}
|
|
49
|
+
else if (arg === "--include-low") {
|
|
50
|
+
includeLow = true;
|
|
51
|
+
}
|
|
52
|
+
else if (arg === "--no-write") {
|
|
53
|
+
writeReport = false;
|
|
54
|
+
}
|
|
55
|
+
else if (arg === "--fail-on") {
|
|
56
|
+
const threshold = args[index + 1];
|
|
57
|
+
if (threshold !== "high" && threshold !== "medium") {
|
|
58
|
+
throw new Error("--fail-on must be either high or medium.");
|
|
59
|
+
}
|
|
60
|
+
failOn = threshold;
|
|
61
|
+
index += 1;
|
|
62
|
+
}
|
|
63
|
+
else if (arg.startsWith("-")) {
|
|
64
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
65
|
+
}
|
|
66
|
+
else if (projectPath) {
|
|
67
|
+
throw new Error("Only one project path may be provided.");
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
projectPath = arg;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
projectRoot: node_path_1.default.resolve(projectPath ?? process.cwd()),
|
|
75
|
+
json,
|
|
76
|
+
mode,
|
|
77
|
+
sinceRef,
|
|
78
|
+
includeLow,
|
|
79
|
+
writeReport,
|
|
80
|
+
failOn,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
async function main() {
|
|
84
|
+
const options = parseArgs(process.argv.slice(2));
|
|
85
|
+
const stat = await node_fs_1.promises.stat(options.projectRoot).catch(() => null);
|
|
86
|
+
if (!stat?.isDirectory()) {
|
|
87
|
+
throw new Error(`Project directory not found: ${options.projectRoot}`);
|
|
88
|
+
}
|
|
89
|
+
const result = await (0, scanner_js_1.scanProject)(options.projectRoot, {
|
|
90
|
+
mode: options.mode,
|
|
91
|
+
sinceRef: options.sinceRef,
|
|
92
|
+
includeLow: options.includeLow,
|
|
93
|
+
});
|
|
94
|
+
const reportPath = node_path_1.default.join(options.projectRoot, "prisma-guard-report.md");
|
|
95
|
+
if (options.writeReport) {
|
|
96
|
+
await node_fs_1.promises.writeFile(reportPath, (0, report_js_1.formatMarkdownReport)(result), "utf8");
|
|
97
|
+
}
|
|
98
|
+
if (options.json) {
|
|
99
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
const reportStatus = options.writeReport
|
|
103
|
+
? `Markdown report: ${reportPath}`
|
|
104
|
+
: "Markdown report: not written (--no-write)";
|
|
105
|
+
process.stdout.write(`${(0, report_js_1.formatTerminalReport)(result)}\n\n${reportStatus}\n`);
|
|
106
|
+
}
|
|
107
|
+
const shouldFail = options.failOn === "high"
|
|
108
|
+
? result.summary.high > 0
|
|
109
|
+
: options.failOn === "medium"
|
|
110
|
+
? result.summary.high > 0 || result.summary.medium > 0
|
|
111
|
+
: false;
|
|
112
|
+
if (shouldFail) {
|
|
113
|
+
process.exitCode = 1;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
main().catch((error) => {
|
|
117
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
118
|
+
process.stderr.write(`prisma-guard-lite: ${message}\n`);
|
|
119
|
+
process.exitCode = 1;
|
|
120
|
+
});
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.formatTerminalReport = formatTerminalReport;
|
|
4
|
+
exports.formatMarkdownReport = formatMarkdownReport;
|
|
5
|
+
const severityLabels = {
|
|
6
|
+
high: "HIGH",
|
|
7
|
+
medium: "MEDIUM",
|
|
8
|
+
low: "LOW",
|
|
9
|
+
};
|
|
10
|
+
const severities = ["high", "medium", "low"];
|
|
11
|
+
function location(finding) {
|
|
12
|
+
return finding.line === null
|
|
13
|
+
? finding.file
|
|
14
|
+
: `${finding.file}:${finding.line}`;
|
|
15
|
+
}
|
|
16
|
+
function formatTerminalReport(result) {
|
|
17
|
+
const lines = [
|
|
18
|
+
"Prisma Guard Lite",
|
|
19
|
+
"",
|
|
20
|
+
`Scan mode: ${result.scanMode}`,
|
|
21
|
+
`Files scanned: ${result.filesScanned} migration ${result.filesScanned === 1 ? "file" : "files"}`,
|
|
22
|
+
`Summary: ${result.summary.high} high, ${result.summary.medium} medium, ${result.summary.low} low`,
|
|
23
|
+
];
|
|
24
|
+
for (const severity of severities) {
|
|
25
|
+
const findings = result.findings.filter((finding) => finding.severity === severity);
|
|
26
|
+
lines.push("", `${severityLabels[severity]} (${findings.length})`);
|
|
27
|
+
if (findings.length === 0) {
|
|
28
|
+
lines.push(" No findings.");
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
findings.forEach((finding, index) => {
|
|
32
|
+
lines.push(` ${index + 1}. ${finding.title}`, ` ${location(finding)}`, ` ${finding.explanation}`, ` Suggested fix: ${finding.suggestedFix}`);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return lines.join("\n");
|
|
36
|
+
}
|
|
37
|
+
function formatMarkdownReport(result) {
|
|
38
|
+
const lines = [
|
|
39
|
+
"# Prisma Guard Lite Report",
|
|
40
|
+
"",
|
|
41
|
+
"## Summary",
|
|
42
|
+
"",
|
|
43
|
+
`- **Scan mode:** ${result.scanMode}`,
|
|
44
|
+
`- **Files scanned:** ${result.filesScanned} migration ${result.filesScanned === 1 ? "file" : "files"}`,
|
|
45
|
+
"",
|
|
46
|
+
"| Severity | Count |",
|
|
47
|
+
"| --- | ---: |",
|
|
48
|
+
`| High | ${result.summary.high} |`,
|
|
49
|
+
`| Medium | ${result.summary.medium} |`,
|
|
50
|
+
`| Low | ${result.summary.low} |`,
|
|
51
|
+
];
|
|
52
|
+
for (const severity of severities) {
|
|
53
|
+
const findings = result.findings.filter((finding) => finding.severity === severity);
|
|
54
|
+
lines.push("", `## ${severityLabels[severity]} Findings`, "");
|
|
55
|
+
if (findings.length === 0) {
|
|
56
|
+
lines.push("No findings.");
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
findings.forEach((finding, index) => {
|
|
60
|
+
lines.push(`### ${index + 1}. ${finding.title}`, "", `- **Severity:** ${severityLabels[finding.severity]}`, `- **File:** \`${finding.file}\``, `- **Line:** ${finding.line ?? "Not available"}`, `- **Explanation:** ${finding.explanation}`, `- **Suggested fix:** ${finding.suggestedFix}`, "");
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
lines.push("## Suggested Next Steps", "", "1. Review all high-severity findings before deployment.", "2. Test risky migrations against a recent production-like backup.", "3. Confirm backups and rollback procedures are ready.", "4. Review medium- and low-severity findings for relevance to your application.", "", "> Prisma Guard Lite is a heuristic pre-deploy scanner, not a guarantee of database safety.", "");
|
|
64
|
+
return lines.join("\n");
|
|
65
|
+
}
|
package/dist/scanner.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.scanProject = scanProject;
|
|
7
|
+
const node_fs_1 = require("node:fs");
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const node_util_1 = require("node:util");
|
|
10
|
+
const node_child_process_1 = require("node:child_process");
|
|
11
|
+
const schemaParser_js_1 = require("./schemaParser.js");
|
|
12
|
+
const sqlChecks_js_1 = require("./sqlChecks.js");
|
|
13
|
+
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
14
|
+
const severityOrder = {
|
|
15
|
+
high: 0,
|
|
16
|
+
medium: 1,
|
|
17
|
+
low: 2,
|
|
18
|
+
};
|
|
19
|
+
async function findMigrationFiles(directory) {
|
|
20
|
+
try {
|
|
21
|
+
const entries = await node_fs_1.promises.readdir(directory, { withFileTypes: true });
|
|
22
|
+
const files = await Promise.all(entries.map(async (entry) => {
|
|
23
|
+
const fullPath = node_path_1.default.join(directory, entry.name);
|
|
24
|
+
if (entry.isDirectory()) {
|
|
25
|
+
return findMigrationFiles(fullPath);
|
|
26
|
+
}
|
|
27
|
+
return entry.isFile() && entry.name === "migration.sql"
|
|
28
|
+
? [fullPath]
|
|
29
|
+
: [];
|
|
30
|
+
}));
|
|
31
|
+
return files.flat();
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error.code === "ENOENT") {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function findLatestMigrationFile(directory) {
|
|
41
|
+
try {
|
|
42
|
+
const entries = await node_fs_1.promises.readdir(directory, { withFileTypes: true });
|
|
43
|
+
const folders = entries
|
|
44
|
+
.filter((entry) => entry.isDirectory())
|
|
45
|
+
.map((entry) => entry.name)
|
|
46
|
+
.sort()
|
|
47
|
+
.reverse();
|
|
48
|
+
for (const folder of folders) {
|
|
49
|
+
const migrationPath = node_path_1.default.join(directory, folder, "migration.sql");
|
|
50
|
+
const stat = await node_fs_1.promises.stat(migrationPath).catch(() => null);
|
|
51
|
+
if (stat?.isFile()) {
|
|
52
|
+
return [migrationPath];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (error.code === "ENOENT") {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function relativeFile(root, filePath) {
|
|
65
|
+
return node_path_1.default.relative(root, filePath).split(node_path_1.default.sep).join("/");
|
|
66
|
+
}
|
|
67
|
+
async function gitLines(projectRoot, args) {
|
|
68
|
+
try {
|
|
69
|
+
const { stdout } = await execFileAsync("git", ["-C", projectRoot, ...args], {
|
|
70
|
+
encoding: "utf8",
|
|
71
|
+
});
|
|
72
|
+
return stdout
|
|
73
|
+
.split(/\r?\n/)
|
|
74
|
+
.map((line) => line.trim())
|
|
75
|
+
.filter(Boolean);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
const stderr = error.stderr?.trim();
|
|
79
|
+
throw new Error(stderr || "This scan mode requires a Git worktree.");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async function existingMigrationPaths(projectRoot, relativePaths) {
|
|
83
|
+
const uniquePaths = [...new Set(relativePaths)];
|
|
84
|
+
const results = await Promise.all(uniquePaths.map(async (relativePath) => {
|
|
85
|
+
const normalized = relativePath.split(node_path_1.default.sep).join("/");
|
|
86
|
+
if (!/^prisma\/migrations\/.+\/migration\.sql$/.test(normalized)) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const fullPath = node_path_1.default.join(projectRoot, relativePath);
|
|
90
|
+
const stat = await node_fs_1.promises.stat(fullPath).catch(() => null);
|
|
91
|
+
return stat?.isFile() ? fullPath : null;
|
|
92
|
+
}));
|
|
93
|
+
return results.filter((filePath) => filePath !== null).sort();
|
|
94
|
+
}
|
|
95
|
+
async function selectMigrationFiles(projectRoot, options) {
|
|
96
|
+
const migrationRoot = node_path_1.default.join(projectRoot, "prisma", "migrations");
|
|
97
|
+
if (options.mode === "history") {
|
|
98
|
+
return (await findMigrationFiles(migrationRoot)).sort();
|
|
99
|
+
}
|
|
100
|
+
if (options.mode === "latest") {
|
|
101
|
+
return findLatestMigrationFile(migrationRoot);
|
|
102
|
+
}
|
|
103
|
+
const pathspec = "prisma/migrations/**/migration.sql";
|
|
104
|
+
if (options.mode === "since") {
|
|
105
|
+
if (!options.sinceRef) {
|
|
106
|
+
throw new Error("--since requires a Git ref.");
|
|
107
|
+
}
|
|
108
|
+
const files = await gitLines(projectRoot, [
|
|
109
|
+
"diff",
|
|
110
|
+
"--relative",
|
|
111
|
+
"--name-only",
|
|
112
|
+
"--diff-filter=ACMR",
|
|
113
|
+
options.sinceRef,
|
|
114
|
+
"--",
|
|
115
|
+
pathspec,
|
|
116
|
+
]);
|
|
117
|
+
return existingMigrationPaths(projectRoot, files);
|
|
118
|
+
}
|
|
119
|
+
if (options.mode === "staged") {
|
|
120
|
+
const files = await gitLines(projectRoot, [
|
|
121
|
+
"diff",
|
|
122
|
+
"--relative",
|
|
123
|
+
"--cached",
|
|
124
|
+
"--name-only",
|
|
125
|
+
"--diff-filter=ACMR",
|
|
126
|
+
"--",
|
|
127
|
+
pathspec,
|
|
128
|
+
]);
|
|
129
|
+
return existingMigrationPaths(projectRoot, files);
|
|
130
|
+
}
|
|
131
|
+
const tracked = await gitLines(projectRoot, [
|
|
132
|
+
"diff",
|
|
133
|
+
"--relative",
|
|
134
|
+
"--name-only",
|
|
135
|
+
"--diff-filter=ACMR",
|
|
136
|
+
"HEAD",
|
|
137
|
+
"--",
|
|
138
|
+
pathspec,
|
|
139
|
+
]);
|
|
140
|
+
const untracked = await gitLines(projectRoot, [
|
|
141
|
+
"ls-files",
|
|
142
|
+
"--others",
|
|
143
|
+
"--exclude-standard",
|
|
144
|
+
"--",
|
|
145
|
+
pathspec,
|
|
146
|
+
]);
|
|
147
|
+
return existingMigrationPaths(projectRoot, [...tracked, ...untracked]);
|
|
148
|
+
}
|
|
149
|
+
function scanModeLabel(options) {
|
|
150
|
+
switch (options.mode) {
|
|
151
|
+
case "latest":
|
|
152
|
+
return "latest migration";
|
|
153
|
+
case "since":
|
|
154
|
+
return `migrations changed since ${options.sinceRef}`;
|
|
155
|
+
case "staged":
|
|
156
|
+
return "staged migrations";
|
|
157
|
+
case "changed":
|
|
158
|
+
return "changed migrations";
|
|
159
|
+
default:
|
|
160
|
+
return "history scan";
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async function scanProject(projectRoot, options) {
|
|
164
|
+
const findings = [];
|
|
165
|
+
const schemaPath = node_path_1.default.join(projectRoot, "prisma", "schema.prisma");
|
|
166
|
+
try {
|
|
167
|
+
const schema = await node_fs_1.promises.readFile(schemaPath, "utf8");
|
|
168
|
+
findings.push(...(0, schemaParser_js_1.checkSchema)(schema, relativeFile(projectRoot, schemaPath), options.includeLow));
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
if (error.code !== "ENOENT") {
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const migrationFiles = await selectMigrationFiles(projectRoot, options);
|
|
176
|
+
for (const migrationPath of migrationFiles) {
|
|
177
|
+
const sql = await node_fs_1.promises.readFile(migrationPath, "utf8");
|
|
178
|
+
findings.push(...(0, sqlChecks_js_1.checkSql)(sql, relativeFile(projectRoot, migrationPath)));
|
|
179
|
+
}
|
|
180
|
+
findings.sort((a, b) => {
|
|
181
|
+
return (severityOrder[a.severity] - severityOrder[b.severity] ||
|
|
182
|
+
a.file.localeCompare(b.file) ||
|
|
183
|
+
(a.line ?? Number.MAX_SAFE_INTEGER) - (b.line ?? Number.MAX_SAFE_INTEGER));
|
|
184
|
+
});
|
|
185
|
+
const summary = {
|
|
186
|
+
high: 0,
|
|
187
|
+
medium: 0,
|
|
188
|
+
low: 0,
|
|
189
|
+
};
|
|
190
|
+
for (const finding of findings) {
|
|
191
|
+
summary[finding.severity] += 1;
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
scanMode: scanModeLabel(options),
|
|
195
|
+
filesScanned: migrationFiles.length,
|
|
196
|
+
summary,
|
|
197
|
+
findings,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.checkSchema = checkSchema;
|
|
4
|
+
const tenantFields = [
|
|
5
|
+
"tenantId",
|
|
6
|
+
"orgId",
|
|
7
|
+
"organizationId",
|
|
8
|
+
"workspaceId",
|
|
9
|
+
"teamId",
|
|
10
|
+
];
|
|
11
|
+
const tenantOwnedModelNames = new Set([
|
|
12
|
+
"project",
|
|
13
|
+
"task",
|
|
14
|
+
"customer",
|
|
15
|
+
"invoice",
|
|
16
|
+
"order",
|
|
17
|
+
"subscription",
|
|
18
|
+
"member",
|
|
19
|
+
"file",
|
|
20
|
+
"document",
|
|
21
|
+
"message",
|
|
22
|
+
"ticket",
|
|
23
|
+
"product",
|
|
24
|
+
]);
|
|
25
|
+
function lineNumberAt(text, index) {
|
|
26
|
+
return text.slice(0, index).split("\n").length;
|
|
27
|
+
}
|
|
28
|
+
function parseModels(schema) {
|
|
29
|
+
const models = [];
|
|
30
|
+
const modelPattern = /\bmodel\s+(\w+)\s*\{([\s\S]*?)\}/g;
|
|
31
|
+
for (const match of schema.matchAll(modelPattern)) {
|
|
32
|
+
const name = match[1];
|
|
33
|
+
const body = match[2];
|
|
34
|
+
const modelStart = match.index ?? 0;
|
|
35
|
+
const bodyStart = modelStart + match[0].indexOf(body);
|
|
36
|
+
const fields = [];
|
|
37
|
+
const indexes = [];
|
|
38
|
+
body.split("\n").forEach((rawLine, offset) => {
|
|
39
|
+
const source = rawLine.trim();
|
|
40
|
+
const line = lineNumberAt(schema, bodyStart) + offset;
|
|
41
|
+
if (!source || source.startsWith("//")) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (source.startsWith("@@index")) {
|
|
45
|
+
indexes.push(source);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (source.startsWith("@@") || source.startsWith("///")) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const fieldMatch = source.match(/^(\w+)\s+\S+/);
|
|
52
|
+
if (fieldMatch) {
|
|
53
|
+
fields.push({ name: fieldMatch[1], line, source });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
models.push({
|
|
57
|
+
name,
|
|
58
|
+
line: lineNumberAt(schema, modelStart),
|
|
59
|
+
fields,
|
|
60
|
+
indexes,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return models;
|
|
64
|
+
}
|
|
65
|
+
function findSchemaPattern(schema, file, pattern, title, explanation, suggestedFix) {
|
|
66
|
+
const findings = [];
|
|
67
|
+
for (const match of schema.matchAll(pattern)) {
|
|
68
|
+
findings.push({
|
|
69
|
+
severity: "medium",
|
|
70
|
+
file,
|
|
71
|
+
line: lineNumberAt(schema, match.index ?? 0),
|
|
72
|
+
title,
|
|
73
|
+
explanation,
|
|
74
|
+
suggestedFix,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return findings;
|
|
78
|
+
}
|
|
79
|
+
function checkSchema(schema, file, includeLow) {
|
|
80
|
+
const findings = [
|
|
81
|
+
...findSchemaPattern(schema, file, /\bUnsupported\s*\(/g, "Schema uses Unsupported(...)", "Unsupported field types are not fully represented by Prisma Client and may need manual handling.", "Confirm the field is intentional and document how application code and migrations handle it."),
|
|
82
|
+
...findSchemaPattern(schema, file, /\bdbgenerated\s*\(/g, "Schema uses dbgenerated(...)", "Database-generated defaults can behave differently across providers and may hide database-specific behavior.", "Verify the expression in every target database and cover it with migration and application tests."),
|
|
83
|
+
];
|
|
84
|
+
for (const model of parseModels(schema)) {
|
|
85
|
+
const fieldNames = new Set(model.fields.map((field) => field.name));
|
|
86
|
+
const deletedAt = model.fields.find((field) => field.name === "deletedAt");
|
|
87
|
+
const uniqueFields = model.fields.filter((field) => /(?:^|\s)@unique(?:\s|$|\()/i.test(field.source));
|
|
88
|
+
if (deletedAt && uniqueFields.length > 0) {
|
|
89
|
+
findings.push({
|
|
90
|
+
severity: "medium",
|
|
91
|
+
file,
|
|
92
|
+
line: uniqueFields[0].line,
|
|
93
|
+
title: `${model.name} combines soft deletion with unique fields`,
|
|
94
|
+
explanation: `The model has deletedAt and unique field(s): ${uniqueFields.map((field) => field.name).join(", ")}. Soft-deleted rows can continue to block reuse of those values.`,
|
|
95
|
+
suggestedFix: "Review whether uniqueness should include deletion state or be enforced with a database-specific partial unique index.",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
for (const tenantField of tenantFields.filter((field) => fieldNames.has(field))) {
|
|
99
|
+
const indexed = model.indexes.some((index) => {
|
|
100
|
+
const indexedFields = index.match(/@@index\s*\(\s*\[([^\]]+)\]/)?.[1] ?? "";
|
|
101
|
+
return indexedFields
|
|
102
|
+
.split(",")
|
|
103
|
+
.map((field) => field.trim().split(/\s+/)[0])
|
|
104
|
+
.includes(tenantField);
|
|
105
|
+
});
|
|
106
|
+
if (!indexed) {
|
|
107
|
+
const field = model.fields.find((candidate) => candidate.name === tenantField);
|
|
108
|
+
findings.push({
|
|
109
|
+
severity: "medium",
|
|
110
|
+
file,
|
|
111
|
+
line: field?.line ?? model.line,
|
|
112
|
+
title: `${model.name}.${tenantField} has no matching index`,
|
|
113
|
+
explanation: "Tenant-scoped queries commonly filter by this field and may slow down as the table grows.",
|
|
114
|
+
suggestedFix: `Add @@index([${tenantField}]) or a compound index beginning with ${tenantField} that matches common queries.`,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (includeLow && !fieldNames.has("createdAt")) {
|
|
119
|
+
findings.push({
|
|
120
|
+
severity: "low",
|
|
121
|
+
file,
|
|
122
|
+
line: model.line,
|
|
123
|
+
title: `${model.name} is missing createdAt`,
|
|
124
|
+
explanation: "Creation timestamps are useful for auditing, debugging, and ordering records.",
|
|
125
|
+
suggestedFix: "Consider adding createdAt DateTime @default(now()).",
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (includeLow && !fieldNames.has("updatedAt")) {
|
|
129
|
+
findings.push({
|
|
130
|
+
severity: "low",
|
|
131
|
+
file,
|
|
132
|
+
line: model.line,
|
|
133
|
+
title: `${model.name} is missing updatedAt`,
|
|
134
|
+
explanation: "Update timestamps are useful for auditing changes and cache synchronization.",
|
|
135
|
+
suggestedFix: "Consider adding updatedAt DateTime @updatedAt.",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
if (includeLow && !fieldNames.has("deletedAt")) {
|
|
139
|
+
findings.push({
|
|
140
|
+
severity: "low",
|
|
141
|
+
file,
|
|
142
|
+
line: model.line,
|
|
143
|
+
title: `${model.name} is missing deletedAt`,
|
|
144
|
+
explanation: "A soft-delete timestamp can help preserve records that should not be removed immediately.",
|
|
145
|
+
suggestedFix: "If this model needs soft deletion, consider adding deletedAt DateTime?.",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const hasTenantField = tenantFields.some((field) => fieldNames.has(field));
|
|
149
|
+
if (includeLow &&
|
|
150
|
+
tenantOwnedModelNames.has(model.name.toLowerCase()) &&
|
|
151
|
+
!hasTenantField) {
|
|
152
|
+
findings.push({
|
|
153
|
+
severity: "low",
|
|
154
|
+
file,
|
|
155
|
+
line: model.line,
|
|
156
|
+
title: `${model.name} may be missing tenant ownership`,
|
|
157
|
+
explanation: "The model name suggests tenant-owned business data, but no common tenant field was found.",
|
|
158
|
+
suggestedFix: "If this data is tenant-owned, add an appropriate tenant identifier and index it. Otherwise, document that the model is global.",
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return findings;
|
|
163
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.checkSql = checkSql;
|
|
4
|
+
const rules = [
|
|
5
|
+
{
|
|
6
|
+
severity: "high",
|
|
7
|
+
pattern: /\bDROP\s+TABLE\b/gi,
|
|
8
|
+
title: "Migration drops a table",
|
|
9
|
+
explanation: "Dropping a table permanently removes its data and can break dependent queries.",
|
|
10
|
+
suggestedFix: "Confirm the table is no longer needed, back up its data, and use a staged removal when possible.",
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
severity: "high",
|
|
14
|
+
pattern: /\bDROP\s+COLUMN\b/gi,
|
|
15
|
+
title: "Migration drops a column",
|
|
16
|
+
explanation: "Dropping a column permanently removes its stored data and may break older application versions.",
|
|
17
|
+
suggestedFix: "Stop reading and writing the column first, deploy that change, then remove the column in a later migration.",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
severity: "high",
|
|
21
|
+
pattern: /\bTRUNCATE(?:\s+TABLE)?\b/gi,
|
|
22
|
+
title: "Migration truncates data",
|
|
23
|
+
explanation: "TRUNCATE removes all rows from a table and is difficult to reverse.",
|
|
24
|
+
suggestedFix: "Verify that full data deletion is intentional and ensure a tested backup or recovery plan exists.",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
severity: "medium",
|
|
28
|
+
pattern: /\bCREATE\s+EXTENSION\b/gi,
|
|
29
|
+
title: "Migration creates a database extension",
|
|
30
|
+
explanation: "Database extensions may require elevated permissions or may not be available in every environment.",
|
|
31
|
+
suggestedFix: "Confirm the extension is supported and provisioned in each target database environment.",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
severity: "medium",
|
|
35
|
+
pattern: /\bDROP\s+EXTENSION\b/gi,
|
|
36
|
+
title: "Migration drops a database extension",
|
|
37
|
+
explanation: "Removing an extension can break database objects or application features that depend on it.",
|
|
38
|
+
suggestedFix: "Identify dependent objects and features before removing the extension.",
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
function lineNumberAt(text, index) {
|
|
42
|
+
return text.slice(0, index).split("\n").length;
|
|
43
|
+
}
|
|
44
|
+
function findingsForRule(sql, file, rule) {
|
|
45
|
+
const findings = [];
|
|
46
|
+
rule.pattern.lastIndex = 0;
|
|
47
|
+
for (const match of sql.matchAll(rule.pattern)) {
|
|
48
|
+
findings.push({
|
|
49
|
+
severity: rule.severity,
|
|
50
|
+
file,
|
|
51
|
+
line: lineNumberAt(sql, match.index ?? 0),
|
|
52
|
+
title: rule.title,
|
|
53
|
+
explanation: rule.explanation,
|
|
54
|
+
suggestedFix: rule.suggestedFix,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return findings;
|
|
58
|
+
}
|
|
59
|
+
function findDeletesWithoutWhere(sql, file) {
|
|
60
|
+
const findings = [];
|
|
61
|
+
const statementPattern = /\bDELETE\s+FROM\b[^;]*/gi;
|
|
62
|
+
for (const match of sql.matchAll(statementPattern)) {
|
|
63
|
+
const statement = match[0];
|
|
64
|
+
if (!/\bWHERE\b/i.test(statement)) {
|
|
65
|
+
findings.push({
|
|
66
|
+
severity: "high",
|
|
67
|
+
file,
|
|
68
|
+
line: lineNumberAt(sql, match.index ?? 0),
|
|
69
|
+
title: "DELETE FROM has no WHERE clause",
|
|
70
|
+
explanation: "A DELETE statement without a WHERE clause removes every row from the target table.",
|
|
71
|
+
suggestedFix: "Add a narrowly scoped WHERE clause, or explicitly verify and document that deleting all rows is intended.",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return findings;
|
|
76
|
+
}
|
|
77
|
+
function findColumnTypeChanges(sql, file) {
|
|
78
|
+
const findings = [];
|
|
79
|
+
const pattern = /\bALTER\s+TABLE\b[^;]*?\bALTER\s+COLUMN\s+(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|\w+)\s+(?:(?:SET\s+DATA\s+)|(?:SET\s+))?TYPE\b/gi;
|
|
80
|
+
for (const match of sql.matchAll(pattern)) {
|
|
81
|
+
findings.push({
|
|
82
|
+
severity: "high",
|
|
83
|
+
file,
|
|
84
|
+
line: lineNumberAt(sql, match.index ?? 0),
|
|
85
|
+
title: "Migration changes a column type",
|
|
86
|
+
explanation: "Changing a column type can rewrite or lock a table and may fail when existing values cannot be converted.",
|
|
87
|
+
suggestedFix: "Test the conversion on production-like data and consider a staged add, backfill, and swap migration.",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return findings;
|
|
91
|
+
}
|
|
92
|
+
function checkSql(sql, file) {
|
|
93
|
+
return [
|
|
94
|
+
...rules.flatMap((rule) => findingsForRule(sql, file, rule)),
|
|
95
|
+
...findColumnTypeChanges(sql, file),
|
|
96
|
+
...findDeletesWithoutWhere(sql, file),
|
|
97
|
+
];
|
|
98
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: Prisma migration guard
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
|
|
6
|
+
jobs:
|
|
7
|
+
prisma-guard:
|
|
8
|
+
runs-on: ubuntu-latest
|
|
9
|
+
steps:
|
|
10
|
+
- uses: actions/checkout@v4
|
|
11
|
+
with:
|
|
12
|
+
fetch-depth: 0
|
|
13
|
+
- uses: actions/setup-node@v4
|
|
14
|
+
with:
|
|
15
|
+
node-version: 22
|
|
16
|
+
cache: npm
|
|
17
|
+
- run: npm install
|
|
18
|
+
- run: npx prisma-guard-lite --since origin/main --fail-on high
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Prisma Guard Lite Report
|
|
2
|
+
|
|
3
|
+
## Summary
|
|
4
|
+
|
|
5
|
+
- **Scan mode:** staged migrations
|
|
6
|
+
- **Files scanned:** 1 migration file
|
|
7
|
+
|
|
8
|
+
| Severity | Count |
|
|
9
|
+
| --- | ---: |
|
|
10
|
+
| High | 0 |
|
|
11
|
+
| Medium | 0 |
|
|
12
|
+
| Low | 0 |
|
|
13
|
+
|
|
14
|
+
## HIGH Findings
|
|
15
|
+
|
|
16
|
+
No findings.
|
|
17
|
+
|
|
18
|
+
## MEDIUM Findings
|
|
19
|
+
|
|
20
|
+
No findings.
|
|
21
|
+
|
|
22
|
+
## LOW Findings
|
|
23
|
+
|
|
24
|
+
No findings.
|
|
25
|
+
|
|
26
|
+
## Suggested Next Steps
|
|
27
|
+
|
|
28
|
+
1. Review the migration as part of the normal deployment process.
|
|
29
|
+
2. Test it against production-like data.
|
|
30
|
+
3. Confirm backups and rollback procedures are ready.
|
|
31
|
+
|
|
32
|
+
> Prisma Guard Lite is a heuristic scanner. It does not guarantee database safety.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Prisma Guard Lite Report
|
|
2
|
+
|
|
3
|
+
## Summary
|
|
4
|
+
|
|
5
|
+
- **Scan mode:** latest migration
|
|
6
|
+
- **Files scanned:** 1 migration file
|
|
7
|
+
|
|
8
|
+
| Severity | Count |
|
|
9
|
+
| --- | ---: |
|
|
10
|
+
| High | 2 |
|
|
11
|
+
| Medium | 1 |
|
|
12
|
+
| Low | 0 |
|
|
13
|
+
|
|
14
|
+
## HIGH Findings
|
|
15
|
+
|
|
16
|
+
### 1. Migration drops a column
|
|
17
|
+
|
|
18
|
+
- **Severity:** HIGH
|
|
19
|
+
- **File:** `prisma/migrations/20260623090000_remove_legacy/migration.sql`
|
|
20
|
+
- **Line:** 4
|
|
21
|
+
- **Explanation:** Dropping a column permanently removes its stored data and may break older application versions.
|
|
22
|
+
- **Suggested fix:** Stop reading and writing the column first, deploy that change, then remove the column in a later migration.
|
|
23
|
+
|
|
24
|
+
### 2. DELETE FROM has no WHERE clause
|
|
25
|
+
|
|
26
|
+
- **Severity:** HIGH
|
|
27
|
+
- **File:** `prisma/migrations/20260623090000_remove_legacy/migration.sql`
|
|
28
|
+
- **Line:** 8
|
|
29
|
+
- **Explanation:** A DELETE statement without a WHERE clause removes every row from the target table.
|
|
30
|
+
- **Suggested fix:** Add a narrowly scoped WHERE clause, or explicitly verify and document that deleting all rows is intended.
|
|
31
|
+
|
|
32
|
+
## MEDIUM Findings
|
|
33
|
+
|
|
34
|
+
### 1. Migration creates a database extension
|
|
35
|
+
|
|
36
|
+
- **Severity:** MEDIUM
|
|
37
|
+
- **File:** `prisma/migrations/20260623090000_remove_legacy/migration.sql`
|
|
38
|
+
- **Line:** 1
|
|
39
|
+
- **Explanation:** Database extensions may require elevated permissions or may not be available in every environment.
|
|
40
|
+
- **Suggested fix:** Confirm the extension is supported and provisioned in each target database environment.
|
|
41
|
+
|
|
42
|
+
## LOW Findings
|
|
43
|
+
|
|
44
|
+
No findings.
|
|
45
|
+
|
|
46
|
+
## Suggested Next Steps
|
|
47
|
+
|
|
48
|
+
1. Review all high-severity findings before deployment.
|
|
49
|
+
2. Test risky migrations against a recent production-like backup.
|
|
50
|
+
3. Confirm backups and rollback procedures are ready.
|
|
51
|
+
|
|
52
|
+
> Prisma Guard Lite is a heuristic scanner. It does not guarantee database safety.
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "prisma-guard-lite",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pre-deploy migration risk checker for Prisma projects",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"prisma",
|
|
7
|
+
"prisma-migrate",
|
|
8
|
+
"database",
|
|
9
|
+
"migrations",
|
|
10
|
+
"migration-checker",
|
|
11
|
+
"ci",
|
|
12
|
+
"postgres",
|
|
13
|
+
"typescript"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"bin": {
|
|
17
|
+
"prisma-guard-lite": "dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE",
|
|
23
|
+
"VALIDATION.md",
|
|
24
|
+
"examples"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"dev": "tsx src/index.ts",
|
|
28
|
+
"build": "tsc"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^22.0.0",
|
|
32
|
+
"tsx": "^4.19.0",
|
|
33
|
+
"typescript": "^5.7.0"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18"
|
|
37
|
+
}
|
|
38
|
+
}
|