@bigsteele/the-prospect 0.3.1 → 0.3.2
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/dist/detect/database.js +34 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +6 -2
- package/package.json +1 -1
package/dist/detect/database.js
CHANGED
|
@@ -85,6 +85,7 @@ export async function detectDatabase(repo) {
|
|
|
85
85
|
* who may call; a bare `grant execute ... to service_role` narrows nothing.
|
|
86
86
|
*/
|
|
87
87
|
const executeRevoked = new Set();
|
|
88
|
+
const definerBodies = [];
|
|
88
89
|
let guard;
|
|
89
90
|
// Two passes: every `alter table` and every `revoke` in the repository is
|
|
90
91
|
// collected first, because a table is very often created in one migration
|
|
@@ -141,13 +142,18 @@ export async function detectDatabase(repo) {
|
|
|
141
142
|
const stmt = stripComments(withComments);
|
|
142
143
|
if (/create\s+(or\s+replace\s+)?policy/i.test(stmt)) {
|
|
143
144
|
policies++;
|
|
144
|
-
|
|
145
|
+
// A quoted name is the whole quoted string (0.3.2): `"anyone reads active
|
|
146
|
+
// plans"` was captured as `anyone`, and the finding was pinned to whichever
|
|
147
|
+
// table the file was named for. The subject now carries the table.
|
|
148
|
+
const m = /create\s+(?:or\s+replace\s+)?policy\s+(?:"([^"]+)"|([a-z0-9_]+))\s+on\s+([a-z0-9_."]+)/i.exec(stmt);
|
|
149
|
+
const name = m ? (m[1] ?? m[2]) : undefined;
|
|
150
|
+
const table = m ? qualify(m[3]) : undefined;
|
|
145
151
|
// `to anon` hands the policy to unauthenticated callers. `to public`
|
|
146
152
|
// is the same reach by another name, since anon is a member of public.
|
|
147
153
|
if (name && /\bto\s+(anon|public)\b/i.test(stmt)) {
|
|
148
154
|
findings.push({
|
|
149
155
|
kind: "policy_reaches_anon",
|
|
150
|
-
subject: name,
|
|
156
|
+
subject: table ? `${name} on ${table}` : name,
|
|
151
157
|
file: f,
|
|
152
158
|
note: "a policy granted to anon or public is readable by an unauthenticated caller. Deliberate for genuinely public rows, and worth one look per policy.",
|
|
153
159
|
});
|
|
@@ -155,8 +161,14 @@ export async function detectDatabase(repo) {
|
|
|
155
161
|
}
|
|
156
162
|
if (/create\s+(or\s+replace\s+)?function/i.test(stmt) && /security\s+definer/i.test(stmt)) {
|
|
157
163
|
definerFunctions++;
|
|
164
|
+
definerBodies.push({ fn: qualify(/create\s+(?:or\s+replace\s+)?function\s+([a-z0-9_."]+)\s*\(/i.exec(stmt)?.[1] ?? "?"), body: stmt });
|
|
158
165
|
const name = /create\s+(?:or\s+replace\s+)?function\s+([a-z0-9_."]+)\s*\(/i.exec(stmt)?.[1];
|
|
159
|
-
|
|
166
|
+
// A TRIGGER FUNCTION CANNOT BE CALLED (0.3.2). Postgres refuses it:
|
|
167
|
+
// "trigger functions can only be called as triggers". Eight of them were
|
|
168
|
+
// reported as callable by anyone on the first deep read, because the
|
|
169
|
+
// shape matched SECURITY DEFINER without reading the return type.
|
|
170
|
+
const isTrigger = /returns\s+trigger\b/i.test(stmt);
|
|
171
|
+
if (name && !isTrigger && !CALLER_CHECK.test(withComments) && !executeRevoked.has(qualify(name))) {
|
|
160
172
|
findings.push({
|
|
161
173
|
kind: "definer_without_check",
|
|
162
174
|
subject: qualify(name),
|
|
@@ -178,12 +190,22 @@ export async function detectDatabase(repo) {
|
|
|
178
190
|
});
|
|
179
191
|
}
|
|
180
192
|
else if (!rlsForced.has(t)) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
193
|
+
// FORCE ONLY MATTERS WHERE THE OWNER READS (0.3.2). FORCE binds the table
|
|
194
|
+
// owner, and clients never connect as the owner; the bypass is reachable
|
|
195
|
+
// only through a SECURITY DEFINER function that runs as the owner and reads
|
|
196
|
+
// the table. Thirty-nine tables were listed on the first deep read and every
|
|
197
|
+
// one was refuted on exactly that point. So: the table is a finding when a
|
|
198
|
+
// definer function names it, and the note says which one.
|
|
199
|
+
const bare = t.split(".").pop();
|
|
200
|
+
const reader = definerBodies.find((d) => new RegExp(`\\b(?:${t.replace(".", "\\.")}|${bare})\\b`, "i").test(d.body.replace(/create\s+(?:or\s+replace\s+)?function\s+[a-z0-9_."]+/i, "")));
|
|
201
|
+
if (reader) {
|
|
202
|
+
findings.push({
|
|
203
|
+
kind: "rls_not_forced",
|
|
204
|
+
subject: t,
|
|
205
|
+
file: f,
|
|
206
|
+
note: `row level security is enabled but not forced, and \`${reader.fn}\` runs as the table owner and reads it, so the owner's bypass is reachable through that function. Forcing it is one line.`,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
187
209
|
}
|
|
188
210
|
}
|
|
189
211
|
return {
|
|
@@ -193,6 +215,8 @@ export async function detectDatabase(repo) {
|
|
|
193
215
|
definer_functions: definerFunctions,
|
|
194
216
|
definer_execute_revoked: executeRevoked.size,
|
|
195
217
|
guard,
|
|
196
|
-
|
|
218
|
+
// Every finding, not the first sixty: the deep read rules on each by id, and a cap
|
|
219
|
+
// hid 87 of 147 on the first real repository while the report said "60 shapes".
|
|
220
|
+
findings: findings.sort((a, b) => a.kind.localeCompare(b.kind) || a.subject.localeCompare(b.subject)).slice(0, 400),
|
|
197
221
|
};
|
|
198
222
|
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -37,7 +37,7 @@ import { scoreProspect } from "./score.js";
|
|
|
37
37
|
export { toMarkdown, secretShaped } from "./report.js";
|
|
38
38
|
export { checkReport } from "./check.js";
|
|
39
39
|
export { checkVerdicts, rescore, showMath, findingIds } from "./verdicts.js";
|
|
40
|
-
export const VERSION = "0.3.
|
|
40
|
+
export const VERSION = "0.3.2";
|
|
41
41
|
export async function runProspect(root) {
|
|
42
42
|
const repo = await openRepo(root);
|
|
43
43
|
const runtime = runtimeCode(repo.files);
|
|
@@ -113,8 +113,12 @@ export async function runProspect(root) {
|
|
|
113
113
|
d.id = `dup:${i + 1}:${d.files[0] ?? ""}`; });
|
|
114
114
|
for (const d of deadReading.dead)
|
|
115
115
|
d.id = `dead:${d.file}`;
|
|
116
|
+
// Only the multiplying shape is a finding. One paid call per request is what a
|
|
117
|
+
// request handler IS; the first deep read ruled on twenty-four of them one by
|
|
118
|
+
// one and refuted every one. They stay in the table as inventory, unnamed.
|
|
116
119
|
for (const c of costs)
|
|
117
|
-
c.
|
|
120
|
+
if (c.shape === "per-row")
|
|
121
|
+
c.id = `cost:${c.file}:${c.line.split(":")[0]}`;
|
|
118
122
|
for (const f of database.findings)
|
|
119
123
|
f.id = `db:${f.kind}:${f.subject}`;
|
|
120
124
|
// Last, and after every detector, so the ledger describes the run that just
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bigsteele/the-prospect",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "A prospector's read of your codebase and your market. Digs the ground you own: every dependency that does no work, every vendor you pay twice, every subsystem you built by hand where a rail now exists. Then surveys the territory: what your industry ships by API that your code still does the hard way. Every suggestion stands on three legs - a fact read from your code, a fact researched from your market with a source and a date, and the thing your product exists to do. Standalone: one npx, no other scan required.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|