Repository navigation
Expand file tree
/
Copy pathcheck-repo-rules.mjs
More file actions
211 lines (198 loc) · 8.08 KB
/
Copy pathcheck-repo-rules.mjs
File metadata and controls
211 lines (198 loc) · 8.08 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
#!/usr/bin/env node
// Repository rules that ESLint cannot see: workflow hardening, pgTAP privilege
// checks, and lock-safety of new migrations. Runs as part of `npm run lint`.
import { execFileSync } from "node:child_process";
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { LineCounter, parseDocument } from "yaml";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const problems = [];
const report = (file, line, message) => problems.push(`${file}:${line}: ${message}`);
// Migrations up to and including this one predate the squawk check and are
// already applied. Newer migrations must pass it.
const MIGRATION_BASELINE = "20261001100000_account_deletion_and_export.sql";
// Squawk rules that do not fit Supabase migrations: each file runs once inside
// a transaction, and column types follow the existing schema.
const SQUAWK_EXCLUDED_RULES = [
"prefer-robust-stmts",
"require-statement-timeout",
"require-lock-timeout",
"prefer-bigint-over-int",
"prefer-bigint-over-smallint",
"prefer-identity",
"prefer-text-field",
"prefer-timestamptz",
];
function listFiles(dir, extensions) {
return readdirSync(path.join(root, dir))
.filter((name) => extensions.some((extension) => name.endsWith(extension)))
.sort()
.map((name) => path.join(dir, name));
}
const lineOf = (source, index) => source.slice(0, index).split("\n").length;
function checkWorkflows() {
for (const file of listFiles(".github/workflows", [".yml", ".yaml"])) {
const lineCounter = new LineCounter();
const doc = parseDocument(readFileSync(path.join(root, file), "utf8"), { lineCounter });
const workflow = doc.toJS();
const lineAt = (keys) => {
const node = doc.getIn(keys, true);
return node?.range ? lineCounter.linePos(node.range[0]).line : 1;
};
if (!workflow || typeof workflow !== "object" || !("permissions" in workflow)) {
report(file, 1, "set a top-level `permissions:` block");
}
const checkPinned = (action, keys) => {
if (action.startsWith("./")) return;
if (action.startsWith("docker://")) {
if (!/@sha256:[0-9a-f]{64}$/.test(action)) report(file, lineAt(keys), `pin ${action} to an image digest (@sha256:...)`);
} else if (!/@[0-9a-f]{40}$/.test(action)) {
report(file, lineAt(keys), `pin ${action} to a full commit SHA`);
}
};
for (const [jobName, job] of Object.entries(workflow?.jobs ?? {})) {
if (typeof job?.uses === "string") checkPinned(job.uses, ["jobs", jobName, "uses"]);
(job?.steps ?? []).forEach((step, index) => {
if (typeof step?.uses !== "string") return;
const keys = ["jobs", jobName, "steps", index, "uses"];
checkPinned(step.uses, keys);
const persist = step.with?.["persist-credentials"];
if (step.uses.startsWith("actions/checkout@") && persist !== false && persist !== "false") {
report(file, lineAt(keys), "set `with.persist-credentials: false` on actions/checkout");
}
});
}
}
}
// Replaces SQL comments with spaces, keeping newlines, quoted strings and
// dollar-quoted bodies, so comments can neither supply nor hide a call's
// arguments. Block comments nest, as in PostgreSQL.
function blankComments(source) {
let out = "";
let index = 0;
const copyUntil = (stop) => {
out += source.slice(index, stop);
index = stop;
};
const blankUntil = (stop) => {
out += source.slice(index, stop).replace(/[^\n]/g, " ");
index = stop;
};
while (index < source.length) {
const dollar = source.slice(index).match(/^\$[A-Za-z_]*\$/);
if (source[index] === "'") {
const end = source.indexOf("'", index + 1);
copyUntil(end === -1 ? source.length : end + 1);
} else if (dollar) {
const end = source.indexOf(dollar[0], index + dollar[0].length);
copyUntil(end === -1 ? source.length : end + dollar[0].length);
} else if (source.startsWith("--", index)) {
const end = source.indexOf("\n", index);
blankUntil(end === -1 ? source.length : end);
} else if (source.startsWith("/*", index)) {
let depth = 0;
let stop = index;
while (stop < source.length) {
if (source.startsWith("/*", stop)) {
depth += 1;
stop += 2;
} else if (source.startsWith("*/", stop)) {
depth -= 1;
stop += 2;
if (depth === 0) break;
} else {
stop += 1;
}
}
blankUntil(stop);
} else {
copyUntil(index + 1);
}
}
return out;
}
// Whitespace and opening parentheses between NOT and the call do not change
// whether the call is negated.
const isNegated = (before) => /\bNOT[\s(]*$/i.test(before);
// Top-level arguments of the call whose opening parenthesis is at `open`,
// split at commas outside parentheses, quoted and dollar-quoted strings.
function callArguments(source, open) {
const args = [];
let depth = 0;
let quoted = false;
let start = open + 1;
for (let index = open; index < source.length; index += 1) {
const char = source[index];
const dollar = quoted ? null : source.slice(index).match(/^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/);
if (dollar) {
const end = source.indexOf(dollar[0], index + dollar[0].length);
if (end === -1) break;
index = end + dollar[0].length - 1;
} else if (char === "'") quoted = !quoted;
else if (quoted) continue;
else if (char === "(") depth += 1;
else if (char === "," && depth === 1) {
args.push(source.slice(start, index));
start = index + 1;
} else if (char === ")" && --depth === 0) {
args.push(source.slice(start, index));
return args;
}
}
return args;
}
function checkPgTapPrivileges() {
// has_table_privilege(role, table, 'SELECT,INSERT') is true when ANY listed
// privilege is held, so a list cannot prove that a role has all of them.
// Under NOT, a list correctly means "none of them". The check errs toward
// flagging: anything but one quoted privilege in a positive call is reported,
// and a negative written as `= false` is rewritten with NOT.
// Optional schema (pg_catalog.) and optional double quotes around either name.
const call = /(?:\b[A-Za-z_]+\.|"[A-Za-z_]+"\.)?(?:\bhas_[a-z_]+_privilege|"has_[a-z_]+_privilege")\s*\(/gi;
for (const file of listFiles("supabase/tests", [".sql"])) {
const source = blankComments(readFileSync(path.join(root, file), "utf8"));
for (const match of source.matchAll(call)) {
// A positive check names one privilege: a plain quoted word, or a column
// that yields one per row (bool_and over unnest). Lists, concatenation,
// dollar quotes and other expressions are flagged.
const privilege = callArguments(source, match.index + match[0].length - 1).at(-1) ?? "";
const single = /^\s*(?:'[A-Za-z ]+'(?:\s*::\s*text)?|[A-Za-z_][A-Za-z0-9_]*)\s*$/.test(privilege);
if (!single && !isNegated(source.slice(0, match.index))) {
report(file, lineOf(source, match.index), "a privilege list means \"any of\": check one privilege per call, or write a negative check as NOT has_*_privilege(...)");
}
}
}
}
function checkNewMigrations() {
const fresh = listFiles("supabase/migrations", [".sql"]).filter(
(file) => path.basename(file) > MIGRATION_BASELINE,
);
if (fresh.length === 0) return;
try {
execFileSync(
path.join(root, "node_modules/.bin/squawk"),
[
"--reporter=gcc",
"--pg-version=17.0",
"--assume-in-transaction",
`--exclude=${SQUAWK_EXCLUDED_RULES.join(",")}`,
...fresh,
],
{ cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
);
} catch (error) {
const output = `${error.stdout ?? ""}${error.stderr ?? ""}`.replace(/\x1b\[[0-9;]*m/g, "").trim();
problems.push(
output || String(error),
"Fix the lock or compatibility problem, or add `-- squawk-ignore <rule>` above the statement with the reason.",
);
}
}
checkWorkflows();
checkPgTapPrivileges();
checkNewMigrations();
if (problems.length > 0) {
console.error(problems.join("\n"));
process.exit(1);
}