@everystack/cli 0.3.0 → 0.3.4
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/package.json +6 -2
- package/src/cli/audit-api.ts +54 -0
- package/src/cli/authz-compile.ts +186 -34
- package/src/cli/authz-owner-probe.ts +22 -4
- package/src/cli/authz-reconcile.ts +25 -0
- package/src/cli/bundle-audit.ts +185 -0
- package/src/cli/bundle-weight.ts +356 -0
- package/src/cli/bundle.ts +146 -0
- package/src/cli/commands/audit.ts +118 -0
- package/src/cli/commands/bundle.ts +452 -0
- package/src/cli/commands/db-authz.ts +13 -5
- package/src/cli/commands/db-generate.ts +79 -1
- package/src/cli/commands/lighthouse.ts +104 -0
- package/src/cli/commands/security-probe.ts +213 -0
- package/src/cli/commands/security.ts +12 -4
- package/src/cli/index.ts +28 -0
- package/src/cli/lighthouse.ts +155 -0
- package/src/cli/migration-compile.ts +71 -7
- package/src/cli/migration-generate.ts +4 -1
- package/src/cli/model-api.ts +1 -0
- package/src/cli/schema-compile.ts +66 -9
- package/src/cli/schema-diff.ts +4 -1
- package/src/cli/schema-source.ts +416 -0
- package/src/cli/security-probe.ts +243 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -29,6 +29,10 @@
|
|
|
29
29
|
"types": "./src/cli/model-api.ts",
|
|
30
30
|
"default": "./src/cli/model-api.ts"
|
|
31
31
|
},
|
|
32
|
+
"./audit": {
|
|
33
|
+
"types": "./src/cli/audit-api.ts",
|
|
34
|
+
"default": "./src/cli/audit-api.ts"
|
|
35
|
+
},
|
|
32
36
|
"./client": {
|
|
33
37
|
"types": "./src/client/index.ts",
|
|
34
38
|
"default": "./src/client/index.ts"
|
|
@@ -65,7 +69,7 @@
|
|
|
65
69
|
"node-forge": "1.4.0",
|
|
66
70
|
"structured-headers": "1.0.1",
|
|
67
71
|
"tsx": "4.21.0",
|
|
68
|
-
"@everystack/model": "0.3.
|
|
72
|
+
"@everystack/model": "0.3.3"
|
|
69
73
|
},
|
|
70
74
|
"peerDependencies": {
|
|
71
75
|
"@everystack/server": ">=0.1.0",
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public audit surface — `@everystack/cli/audit`.
|
|
3
|
+
*
|
|
4
|
+
* The governance MCP imports these pure scanners directly to gate authoring and
|
|
5
|
+
* pre-deploy: the secret-leak checks (bundle-audit) and the weight/size checks
|
|
6
|
+
* (bundle-weight), plus the bundle composition helpers they build on. Pure,
|
|
7
|
+
* filesystem-free, no CLI/IO coupling — feed them text or {path, size} lists.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
// types
|
|
12
|
+
type AuditFinding,
|
|
13
|
+
type SecretPattern,
|
|
14
|
+
// leak scanners
|
|
15
|
+
SECRET_PATTERNS,
|
|
16
|
+
redact,
|
|
17
|
+
isPublicEnvKey,
|
|
18
|
+
scanSecretShapes,
|
|
19
|
+
extractBundleUrls,
|
|
20
|
+
extractExtraEnvKeys,
|
|
21
|
+
classifyPrivateEnvKeys,
|
|
22
|
+
scanSecretValue,
|
|
23
|
+
summarize,
|
|
24
|
+
} from './bundle-audit.js';
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
// types
|
|
28
|
+
type AssetFile,
|
|
29
|
+
type AssetKind,
|
|
30
|
+
type WeightBudget,
|
|
31
|
+
// weight scanners
|
|
32
|
+
DEFAULT_WEIGHT_BUDGET,
|
|
33
|
+
classifyAsset,
|
|
34
|
+
formatBytes,
|
|
35
|
+
scanAssetWeights,
|
|
36
|
+
scanTotalWeight,
|
|
37
|
+
scanJsWeight,
|
|
38
|
+
scanSourceMapsPresent,
|
|
39
|
+
scanDependencyWeight,
|
|
40
|
+
resolveWeightBudget,
|
|
41
|
+
} from './bundle-weight.js';
|
|
42
|
+
|
|
43
|
+
export {
|
|
44
|
+
// composition (input to scanDependencyWeight)
|
|
45
|
+
type Composition,
|
|
46
|
+
type ChunkInput,
|
|
47
|
+
type ChunkSize,
|
|
48
|
+
type SourceMap,
|
|
49
|
+
measureChunk,
|
|
50
|
+
measureChunks,
|
|
51
|
+
totalSizes,
|
|
52
|
+
bucketSource,
|
|
53
|
+
composeSourceMap,
|
|
54
|
+
} from './bundle.js';
|
package/src/cli/authz-compile.ts
CHANGED
|
@@ -75,6 +75,22 @@ function softDeleteGuard(sqlColumn: string): string {
|
|
|
75
75
|
return `(${sqlColumn} IS NULL)`;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* The transitive-owner (`via:`) predicate, in deparser normal form — a parent
|
|
80
|
+
* subquery that scopes the child to rows whose FK points at a parent the caller
|
|
81
|
+
* owns. The exact whitespace (3 spaces before FROM, 2 before WHERE, the newlines)
|
|
82
|
+
* is what `pg_get_expr` emits and the contract stores, so it is reproduced byte
|
|
83
|
+
* for byte; the inner owner predicate reuses {@link ownerPredicate} (table-qualified):
|
|
84
|
+
*
|
|
85
|
+
* (upload_id IN ( SELECT uploads.id
|
|
86
|
+
* FROM uploads
|
|
87
|
+
* WHERE (uploads.user_id = ((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text))))
|
|
88
|
+
*/
|
|
89
|
+
function viaPredicate(v: ViaRef): string {
|
|
90
|
+
const parentOwner = ownerPredicate(`${v.parentTable}.${v.parentOwner.sqlColumn}`, v.parentOwner.type, v.parentOwner.claim);
|
|
91
|
+
return `(${v.fkColumn} IN ( SELECT ${v.parentTable}.${v.parentPk}\n FROM ${v.parentTable}\n WHERE ${parentOwner}))`;
|
|
92
|
+
}
|
|
93
|
+
|
|
78
94
|
// ---------------------------------------------------------------------------
|
|
79
95
|
// Ability classification.
|
|
80
96
|
// ---------------------------------------------------------------------------
|
|
@@ -96,10 +112,18 @@ interface OwnerRef {
|
|
|
96
112
|
export function modelOwner(model: ModelDescriptor, opts: CompileOptions = {}): { table: string; ownerColumn: string; claim: string } | null {
|
|
97
113
|
const owner = resolveOwner(model);
|
|
98
114
|
if (!owner) return null;
|
|
99
|
-
const schema = opts
|
|
115
|
+
const schema = resolveSchema(model, opts);
|
|
100
116
|
return { table: `${schema}.${model.table}`, ownerColumn: owner.sqlColumn, claim: owner.claim };
|
|
101
117
|
}
|
|
102
118
|
|
|
119
|
+
/**
|
|
120
|
+
* The schema a model's table lives in. The model's own `schema` wins (a 'dist'
|
|
121
|
+
* model is dist in any run), then the generate call's default, then 'public'.
|
|
122
|
+
*/
|
|
123
|
+
function resolveSchema(model: ModelDescriptor, opts: CompileOptions): string {
|
|
124
|
+
return model.schema ?? opts.schema ?? 'public';
|
|
125
|
+
}
|
|
126
|
+
|
|
103
127
|
/** Resolve the owner column (SQL name + type + claim) from the first owner ability. */
|
|
104
128
|
function resolveOwner(model: ModelDescriptor): OwnerRef | null {
|
|
105
129
|
const ownerAbility = model.abilities.find((a) => a.condition.owner);
|
|
@@ -112,11 +136,79 @@ function resolveOwner(model: ModelDescriptor): OwnerRef | null {
|
|
|
112
136
|
};
|
|
113
137
|
}
|
|
114
138
|
|
|
139
|
+
interface ViaRef {
|
|
140
|
+
/** The child's FK column, snake_cased (e.g. `upload_id`). */
|
|
141
|
+
fkColumn: string;
|
|
142
|
+
/** The referenced parent table (e.g. `uploads`). */
|
|
143
|
+
parentTable: string;
|
|
144
|
+
/** The parent's primary-key column, snake_cased (e.g. `id`). */
|
|
145
|
+
parentPk: string;
|
|
146
|
+
/** The parent's resolved owner — the predicate scopes the subquery on this. */
|
|
147
|
+
parentOwner: OwnerRef;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Resolve the transitive-owner spec from the first `via:` ability. The named field
|
|
152
|
+
* must carry `.references()`; the compiler follows that thunk to the parent
|
|
153
|
+
* descriptor and resolves the parent's own `owner` ability. Throws a clear error
|
|
154
|
+
* when the field has no FK or the parent declares no owner — a `via:` that cannot
|
|
155
|
+
* reach an owner is a model bug, not a silently-empty policy.
|
|
156
|
+
*/
|
|
157
|
+
function resolveVia(model: ModelDescriptor): ViaRef | null {
|
|
158
|
+
const viaAbility = model.abilities.find((a) => a.condition.via);
|
|
159
|
+
if (!viaAbility) return null;
|
|
160
|
+
const fieldKey = viaAbility.condition.via!;
|
|
161
|
+
const ref = model.fields[fieldKey]?.spec.references;
|
|
162
|
+
if (!ref) {
|
|
163
|
+
throw new Error(`via: '${fieldKey}' on '${model.table}' must name a field declared with .references()`);
|
|
164
|
+
}
|
|
165
|
+
const parent = ref() as ModelDescriptor;
|
|
166
|
+
const parentOwner = resolveOwner(parent);
|
|
167
|
+
if (!parentOwner) {
|
|
168
|
+
throw new Error(`via: parent model '${parent.table}' (from '${model.table}.${fieldKey}') declares no owner ability`);
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
fkColumn: toSnakeCase(fieldKey),
|
|
172
|
+
parentTable: parent.table,
|
|
173
|
+
parentPk: toSnakeCase(parent.primaryKey[0] ?? 'id'),
|
|
174
|
+
parentOwner,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
115
178
|
/** The conventional soft-delete column SQL name, when the model has one. */
|
|
116
179
|
function softDeleteColumn(model: ModelDescriptor): string | null {
|
|
117
180
|
return 'deletedAt' in model.fields ? 'deleted_at' : null;
|
|
118
181
|
}
|
|
119
182
|
|
|
183
|
+
/**
|
|
184
|
+
* A column-scoped self read — `can('read', { owner, columns })`. It compiles to a COLUMN
|
|
185
|
+
* grant (`GRANT SELECT (cols)`) plus a `<table>_select_self` policy, NOT a table grant and
|
|
186
|
+
* NOT a `_select_own` policy: the row is scoped by the owner predicate, the fields by the
|
|
187
|
+
* column list (`auth.users` → id/email/role to the owner, never the password hash). It is
|
|
188
|
+
* excluded from the table-grant and owner-read paths so the two never double-emit.
|
|
189
|
+
*/
|
|
190
|
+
function isColumnRead(a: Ability): boolean {
|
|
191
|
+
return a.action === 'read' && Boolean(a.condition.owner) && Boolean(a.condition.columns?.length);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Column grants from the column-scoped read abilities: the read role (`authenticated`, since
|
|
196
|
+
* the ability is owner-scoped, not role-gated) gets `SELECT (cols)`. Field keys are snake_cased
|
|
197
|
+
* to SQL columns and sorted, matching the introspected contract. Returns undefined when no
|
|
198
|
+
* ability declares columns — an introspected contract omits the key rather than carry an empty
|
|
199
|
+
* object, so the compiled one must too (or the round-trip diff would false-fire).
|
|
200
|
+
*/
|
|
201
|
+
function compileColumnGrants(abilities: readonly Ability[]): Record<string, Record<string, string[]>> | undefined {
|
|
202
|
+
const out: Record<string, Record<string, string[]>> = {};
|
|
203
|
+
for (const a of abilities) {
|
|
204
|
+
if (!isColumnRead(a)) continue;
|
|
205
|
+
const role = a.condition.role ?? 'authenticated';
|
|
206
|
+
const cols = [...a.condition.columns!].map(toSnakeCase).sort();
|
|
207
|
+
(out[role] ??= {}).SELECT = cols;
|
|
208
|
+
}
|
|
209
|
+
return Object.keys(out).length ? out : undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
120
212
|
// ---------------------------------------------------------------------------
|
|
121
213
|
// Compile.
|
|
122
214
|
// ---------------------------------------------------------------------------
|
|
@@ -127,20 +219,34 @@ function softDeleteColumn(model: ModelDescriptor): string | null {
|
|
|
127
219
|
* output is byte-comparable with an introspected contract.
|
|
128
220
|
*/
|
|
129
221
|
export function compileTableContract(model: ModelDescriptor, opts: CompileOptions = {}): TableContract {
|
|
130
|
-
const schema = opts
|
|
222
|
+
const schema = resolveSchema(model, opts);
|
|
131
223
|
const table = `${schema}.${model.table}`;
|
|
132
224
|
const owner = resolveOwner(model);
|
|
225
|
+
const via = resolveVia(model);
|
|
133
226
|
const sdCol = softDeleteColumn(model);
|
|
134
|
-
|
|
227
|
+
// The row-scoping predicate: a direct owner column, else a transitive `via:`
|
|
228
|
+
// parent subquery. Both drive the same owner-gated policies (select/insert/
|
|
229
|
+
// update/delete_own); only the predicate text differs.
|
|
230
|
+
const rowPred = owner
|
|
231
|
+
? ownerPredicate(owner.sqlColumn, owner.type, owner.claim)
|
|
232
|
+
: via
|
|
233
|
+
? viaPredicate(via)
|
|
234
|
+
: null;
|
|
135
235
|
const sdGuard = sdCol ? softDeleteGuard(sdCol) : null;
|
|
136
236
|
|
|
237
|
+
// A "row-scoped" condition is either a direct owner or a transitive via.
|
|
238
|
+
const rowScoped = (a: Ability): boolean => Boolean(a.condition.owner || a.condition.via);
|
|
239
|
+
|
|
137
240
|
const abilities = model.abilities;
|
|
138
241
|
const hasAdminManage = abilities.some((a) => a.action === 'manage' && a.condition.role === 'admin');
|
|
139
|
-
const hasPublicRead = abilities.some((a) => a.action === 'read' && !a.condition.role && !a
|
|
140
|
-
|
|
242
|
+
const hasPublicRead = abilities.some((a) => a.action === 'read' && !a.condition.role && !rowScoped(a));
|
|
243
|
+
// A column-scoped read is row-scoped but emits a self policy + column grant, not the
|
|
244
|
+
// full-row owner read — so it is excluded here and handled by its own branch.
|
|
245
|
+
const hasOwnerRead = abilities.some((a) => a.action === 'read' && rowScoped(a) && !isColumnRead(a));
|
|
246
|
+
const hasColumnRead = abilities.some(isColumnRead);
|
|
141
247
|
const canCreate = abilities.some((a) => a.action === 'create');
|
|
142
|
-
const hasUpdateOwner = abilities.some((a) => a.action === 'update' && a
|
|
143
|
-
const hasDeleteOwner = abilities.some((a) => a.action === 'delete' && a
|
|
248
|
+
const hasUpdateOwner = abilities.some((a) => a.action === 'update' && rowScoped(a));
|
|
249
|
+
const hasDeleteOwner = abilities.some((a) => a.action === 'delete' && rowScoped(a));
|
|
144
250
|
|
|
145
251
|
const t = model.table;
|
|
146
252
|
const policies: PolicyContract[] = [];
|
|
@@ -161,51 +267,97 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
161
267
|
policy(`${t}_select_anon`, 'SELECT', ['anon'], anonUsing, null);
|
|
162
268
|
|
|
163
269
|
let authedUsing = anonUsing;
|
|
164
|
-
if (sdGuard &&
|
|
165
|
-
else if (
|
|
270
|
+
if (sdGuard && rowPred) authedUsing = `(${sdGuard} OR ${rowPred})`;
|
|
271
|
+
else if (rowPred && !sdGuard) authedUsing = rowPred;
|
|
166
272
|
policy(`${t}_select_authenticated`, 'SELECT', ['authenticated'], authedUsing, null);
|
|
167
|
-
} else if (hasOwnerRead &&
|
|
168
|
-
// owner-
|
|
169
|
-
|
|
273
|
+
} else if (hasOwnerRead && rowPred) {
|
|
274
|
+
// owner-scoped read — no anon visibility; you see only the rows you own
|
|
275
|
+
// (directly, or transitively through a `via:` parent).
|
|
276
|
+
policy(`${t}_select_own`, 'SELECT', ['authenticated'], rowPred, null);
|
|
277
|
+
} else if (hasColumnRead && rowPred) {
|
|
278
|
+
// column-scoped self read — `authenticated` reads only its OWN row, and only the
|
|
279
|
+
// granted columns (the GRANT scopes fields; this policy scopes rows). Named
|
|
280
|
+
// `_select_self`, distinct from the full-row `_select_own`.
|
|
281
|
+
policy(`${t}_select_self`, 'SELECT', ['authenticated'], rowPred, null);
|
|
170
282
|
}
|
|
171
283
|
|
|
172
284
|
// owner-gated writes. INSERT checks ownership (you can only create rows you own);
|
|
173
|
-
// UPDATE gates + checks; DELETE gates.
|
|
174
|
-
if (
|
|
175
|
-
if (canCreate) policy(`${t}_insert_own`, 'INSERT', ['authenticated'], null,
|
|
176
|
-
if (hasUpdateOwner) policy(`${t}_update_own`, 'UPDATE', ['authenticated'],
|
|
177
|
-
if (hasDeleteOwner) policy(`${t}_delete_own`, 'DELETE', ['authenticated'],
|
|
285
|
+
// UPDATE gates + checks; DELETE gates. `rowPred` is the direct-owner or `via:` predicate.
|
|
286
|
+
if (rowPred) {
|
|
287
|
+
if (canCreate) policy(`${t}_insert_own`, 'INSERT', ['authenticated'], null, rowPred);
|
|
288
|
+
if (hasUpdateOwner) policy(`${t}_update_own`, 'UPDATE', ['authenticated'], rowPred, rowPred);
|
|
289
|
+
if (hasDeleteOwner) policy(`${t}_delete_own`, 'DELETE', ['authenticated'], rowPred, null);
|
|
178
290
|
}
|
|
179
291
|
|
|
180
292
|
policies.sort((a, b) => a.name.localeCompare(b.name));
|
|
181
293
|
|
|
182
294
|
return {
|
|
183
295
|
table,
|
|
184
|
-
|
|
185
|
-
|
|
296
|
+
// The FORCE axiom: FORCE iff the write principal is subject to its own RLS
|
|
297
|
+
// policies. 'app' writes through its policies -> FORCE; 'worker'/'functions'
|
|
298
|
+
// write on the owner connection and bypass RLS -> ENABLE-not-FORCE (on RDS the
|
|
299
|
+
// owner is not a superuser, so a FORCEd table would block the owner's own writes).
|
|
300
|
+
rls: { enabled: true, forced: model.writtenBy === 'app' },
|
|
301
|
+
grants: compileGrants(abilities),
|
|
302
|
+
...(compileColumnGrants(abilities) ? { columnGrants: compileColumnGrants(abilities) } : {}),
|
|
186
303
|
policies,
|
|
187
304
|
};
|
|
188
305
|
}
|
|
189
306
|
|
|
307
|
+
/** The four DML privileges, sorted — the expansion of a `manage` ability. */
|
|
308
|
+
const CRUD = ['DELETE', 'INSERT', 'SELECT', 'UPDATE'];
|
|
309
|
+
|
|
310
|
+
/** The SQL privilege a single CRUD verb grants. `manage` expands to all of CRUD. */
|
|
311
|
+
const VERB: Record<Exclude<Ability['action'], 'manage'>, string> = {
|
|
312
|
+
read: 'SELECT',
|
|
313
|
+
create: 'INSERT',
|
|
314
|
+
update: 'UPDATE',
|
|
315
|
+
delete: 'DELETE',
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
/** The privileges an ability's action grants (a single verb, or all of CRUD for `manage`). */
|
|
319
|
+
function verbsFor(action: Ability['action']): string[] {
|
|
320
|
+
return action === 'manage' ? [...CRUD] : [VERB[action]];
|
|
321
|
+
}
|
|
322
|
+
|
|
190
323
|
/**
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
324
|
+
* Precise grants — derive each role's privilege set from the abilities, not a
|
|
325
|
+
* uniform tier. everystack's model is coarse grants + fine-grained RLS: the grant
|
|
326
|
+
* is the gate (which roles may touch the table at all), RLS restricts which rows.
|
|
327
|
+
* A role gets a privilege iff some ability grants that verb to it:
|
|
328
|
+
*
|
|
329
|
+
* - `can(action, { role })` -> that role gets the verb(s)
|
|
330
|
+
* - public/owner/via ability (no role) -> `authenticated` gets the verb(s)
|
|
331
|
+
* - a *public* read (no role, no owner/via) -> `anon` also gets SELECT
|
|
196
332
|
*
|
|
197
|
-
*
|
|
198
|
-
* table
|
|
199
|
-
*
|
|
200
|
-
*
|
|
333
|
+
* No ability for a role means no grant for that role: an ability-less (internal)
|
|
334
|
+
* table grants nothing, an owner-only table grants no anon SELECT, an admin-managed
|
|
335
|
+
* table grants admin CRUD only. Deterministic: roles and privilege lists sorted,
|
|
336
|
+
* so the output is byte-comparable with an introspected contract.
|
|
201
337
|
*/
|
|
202
|
-
function compileGrants(
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
...(hasAdminManage ? { admin: [...crud] } : {}),
|
|
338
|
+
function compileGrants(abilities: readonly Ability[]): Record<string, string[]> {
|
|
339
|
+
const grants: Record<string, Set<string>> = {};
|
|
340
|
+
const add = (role: string, verbs: string[]): void => {
|
|
341
|
+
const set = (grants[role] ??= new Set<string>());
|
|
342
|
+
for (const v of verbs) set.add(v);
|
|
208
343
|
};
|
|
344
|
+
for (const a of abilities) {
|
|
345
|
+
// A column-scoped read grants `SELECT (cols)` (a column grant), not a table-level
|
|
346
|
+
// privilege — emitted by compileColumnGrants, skipped here so it never leaks a
|
|
347
|
+
// whole-table grant that would defeat the column scoping.
|
|
348
|
+
if (isColumnRead(a)) continue;
|
|
349
|
+
const verbs = verbsFor(a.action);
|
|
350
|
+
if (a.condition.role) {
|
|
351
|
+
add(a.condition.role, verbs);
|
|
352
|
+
} else {
|
|
353
|
+
add('authenticated', verbs);
|
|
354
|
+
// A public read (no role, not owner/via-scoped) is anon-visible.
|
|
355
|
+
if (a.action === 'read' && !a.condition.owner && !a.condition.via) add('anon', ['SELECT']);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const out: Record<string, string[]> = {};
|
|
359
|
+
for (const role of Object.keys(grants).sort()) out[role] = [...grants[role]].sort();
|
|
360
|
+
return out;
|
|
209
361
|
}
|
|
210
362
|
|
|
211
363
|
// Kept exported for callers that want just the privilege set for a role tier.
|
|
@@ -34,6 +34,13 @@ export interface OwnerProbe {
|
|
|
34
34
|
claim?: string;
|
|
35
35
|
/** App role to assume. Default `authenticated`. */
|
|
36
36
|
role?: string;
|
|
37
|
+
/**
|
|
38
|
+
* The table has an intended PUBLIC read (a `can('read')` with no owner/role/via — anon /
|
|
39
|
+
* authenticated may SELECT every row). Reader isolation is then NOT expected (a Twitter-clone's
|
|
40
|
+
* posts/profiles/likes/follows are public), so a visible other-owner row is by design, not an
|
|
41
|
+
* IDOR. Writer isolation is still enforced — the rows are owner-WRITE even when public-read.
|
|
42
|
+
*/
|
|
43
|
+
publicRead?: boolean;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
export type OwnerCheck = 'discover' | 'read-own' | 'read-other' | 'write-other';
|
|
@@ -49,7 +56,7 @@ export interface OwnerProbeResult {
|
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
export interface OwnerFinding {
|
|
52
|
-
severity: 'read-leak' | 'write-leak' | 'vacuous' | 'unprobed';
|
|
59
|
+
severity: 'read-leak' | 'write-leak' | 'vacuous' | 'unprobed' | 'public-read';
|
|
53
60
|
table: string;
|
|
54
61
|
detail: string;
|
|
55
62
|
}
|
|
@@ -164,8 +171,12 @@ export function toOwnerProbeResult(row: any): OwnerProbeResult {
|
|
|
164
171
|
* - unprobed — fewer than two distinct owners in the table; isolation can't be tested (a
|
|
165
172
|
* warning, not a verdict — seed a second owner's row, or accept the gap).
|
|
166
173
|
* A correctly-isolated table with two owners produces zero findings.
|
|
174
|
+
*
|
|
175
|
+
* `publicReadTables` names tables with an intended public read: their reader-isolation check is
|
|
176
|
+
* informational (`public-read`), not a leak — but writer isolation is still enforced, so a
|
|
177
|
+
* write-leak on a public-read table is still a failure.
|
|
167
178
|
*/
|
|
168
|
-
export function evaluateOwnerProbe(results: OwnerProbeResult[]): OwnerFinding[] {
|
|
179
|
+
export function evaluateOwnerProbe(results: OwnerProbeResult[], publicReadTables: Set<string> = new Set()): OwnerFinding[] {
|
|
169
180
|
const findings: OwnerFinding[] = [];
|
|
170
181
|
const byTable = new Map<string, Map<OwnerCheck, OwnerProbeResult>>();
|
|
171
182
|
for (const r of results) {
|
|
@@ -181,6 +192,7 @@ export function evaluateOwnerProbe(results: OwnerProbeResult[]): OwnerFinding[]
|
|
|
181
192
|
const own = checks.get('read-own');
|
|
182
193
|
const other = checks.get('read-other');
|
|
183
194
|
const write = checks.get('write-other');
|
|
195
|
+
const publicRead = publicReadTables.has(table);
|
|
184
196
|
|
|
185
197
|
if (own && own.outcome !== 'visible') {
|
|
186
198
|
findings.push({ severity: 'vacuous', table,
|
|
@@ -188,8 +200,14 @@ export function evaluateOwnerProbe(results: OwnerProbeResult[]): OwnerFinding[]
|
|
|
188
200
|
continue; // the other checks are meaningless while the owner can't see own rows
|
|
189
201
|
}
|
|
190
202
|
if (other && other.outcome === 'visible') {
|
|
191
|
-
|
|
192
|
-
|
|
203
|
+
// A public-read table is SUPPOSED to expose every row — that is not an IDOR. Report it as
|
|
204
|
+
// informational so the operator sees it was recognized, not a leak. Owner-private tables
|
|
205
|
+
// (no public read) still fail here.
|
|
206
|
+
findings.push(publicRead
|
|
207
|
+
? { severity: 'public-read', table,
|
|
208
|
+
detail: `${table} has an intended public read — reader isolation not applicable (the ${other.rows} other-owner row(s) are public by design); writer isolation still enforced` }
|
|
209
|
+
: { severity: 'read-leak', table,
|
|
210
|
+
detail: `as the owner, ${other.rows} of another user's row(s) in ${table} were visible — RLS does not isolate readers (IDOR read)` });
|
|
193
211
|
}
|
|
194
212
|
if (write && write.outcome === 'visible') {
|
|
195
213
|
findings.push({ severity: 'write-leak', table,
|
|
@@ -84,6 +84,7 @@ export function emitReconcileSql(declared: AuthzContract, live: AuthzContract):
|
|
|
84
84
|
reconcileRls(table, d, l, sql);
|
|
85
85
|
reconcilePolicies(table, d, l, sql);
|
|
86
86
|
reconcileGrants(table, d, l, sql);
|
|
87
|
+
reconcileColumnGrants(table, d, l, sql);
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
return sql;
|
|
@@ -125,3 +126,27 @@ function reconcileGrants(table: string, d: TableContract, l: TableContract | und
|
|
|
125
126
|
if (toGrant.length) out.push(`GRANT ${toGrant.join(', ')} ON ${table} TO ${target};`);
|
|
126
127
|
}
|
|
127
128
|
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Reconcile column-scoped grants — `GRANT SELECT (cols) ON t TO role`. The grantee/privilege
|
|
132
|
+
* grid is reconciled per (grantee, privilege): add the columns declared-but-not-live, revoke
|
|
133
|
+
* those live-but-not-declared. This is how `auth.users` exposes id/email/role (and only those)
|
|
134
|
+
* to `authenticated` without a whole-table grant. Columns are sorted, so a re-pull is a no-op.
|
|
135
|
+
*/
|
|
136
|
+
function reconcileColumnGrants(table: string, d: TableContract, l: TableContract | undefined, out: string[]): void {
|
|
137
|
+
const dcg = d.columnGrants ?? {};
|
|
138
|
+
const lcg = l?.columnGrants ?? {};
|
|
139
|
+
for (const grantee of [...new Set([...Object.keys(dcg), ...Object.keys(lcg)])].sort()) {
|
|
140
|
+
const target = grantee.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : grantee;
|
|
141
|
+
const dPriv = dcg[grantee] ?? {};
|
|
142
|
+
const lPriv = lcg[grantee] ?? {};
|
|
143
|
+
for (const priv of [...new Set([...Object.keys(dPriv), ...Object.keys(lPriv)])].sort()) {
|
|
144
|
+
const declared = new Set(dPriv[priv] ?? []);
|
|
145
|
+
const live = new Set(lPriv[priv] ?? []);
|
|
146
|
+
const toRevoke = [...live].filter((c) => !declared.has(c)).sort();
|
|
147
|
+
const toGrant = [...declared].filter((c) => !live.has(c)).sort();
|
|
148
|
+
if (toRevoke.length) out.push(`REVOKE ${priv} (${toRevoke.join(', ')}) ON ${table} FROM ${target};`);
|
|
149
|
+
if (toGrant.length) out.push(`GRANT ${priv} (${toGrant.join(', ')}) ON ${table} TO ${target};`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundle secret-leak audit — pure scanners for `everystack bundle:audit`.
|
|
3
|
+
*
|
|
4
|
+
* Scans the *served* client bundle for leaked secrets. Config-free by leaning on
|
|
5
|
+
* universal truths instead of a hand-maintained deny-list:
|
|
6
|
+
*
|
|
7
|
+
* 1. Generic secret SHAPES (Postgres/Mongo/Redis URLs, sk_live_, AKIA…, PEM
|
|
8
|
+
* blocks) — these match a secret's *value* regardless of its key name.
|
|
9
|
+
* 2. The Expo convention: Metro only inlines `EXPO_PUBLIC_*` env vars into the
|
|
10
|
+
* client bundle. Any *other* env key serialized into the bundle is, by
|
|
11
|
+
* definition, a private value that leaked.
|
|
12
|
+
* 3. (Operator-run) value canary: grep the bundle for the literal values of the
|
|
13
|
+
* deployed secrets — the only way to catch a Metro-inlined value whose key
|
|
14
|
+
* name never appears. Values are redacted in output; never printed.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here reads secret values; the canary matcher receives them from the
|
|
17
|
+
* command's operator-run path.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface AuditFinding {
|
|
21
|
+
severity: 'fail' | 'warn';
|
|
22
|
+
check:
|
|
23
|
+
// leak checks (this file)
|
|
24
|
+
| 'secret-shape'
|
|
25
|
+
| 'source-map'
|
|
26
|
+
| 'private-env-key'
|
|
27
|
+
| 'secret-value'
|
|
28
|
+
// weight/size checks (bundle-weight.ts)
|
|
29
|
+
| 'asset-weight'
|
|
30
|
+
| 'total-weight'
|
|
31
|
+
| 'data-in-bundle'
|
|
32
|
+
| 'js-weight'
|
|
33
|
+
| 'dep-weight';
|
|
34
|
+
source: string;
|
|
35
|
+
detail: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SecretPattern {
|
|
39
|
+
name: string;
|
|
40
|
+
re: RegExp;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Generic, app-agnostic secret shapes. A value matching any of these in a
|
|
44
|
+
* client bundle is a leak no matter what env key carried it. */
|
|
45
|
+
export const SECRET_PATTERNS: SecretPattern[] = [
|
|
46
|
+
{ name: 'postgres-url-with-credentials', re: /postgres(?:ql)?:\/\/[^:\s'"`]+:[^@\s'"`]+@/ },
|
|
47
|
+
{ name: 'mongodb-url-with-credentials', re: /mongodb(?:\+srv)?:\/\/[^:\s'"`]+:[^@\s'"`]+@/ },
|
|
48
|
+
{ name: 'redis-url-with-credentials', re: /redis:\/\/[^:\s'"`]*:[^@\s'"`]+@/ },
|
|
49
|
+
{ name: 'stripe-secret-key', re: /sk_(?:live|test)_[A-Za-z0-9]{10,}/ },
|
|
50
|
+
{ name: 'mapbox-secret-token', re: /sk\.[A-Za-z0-9_-]{60,}/ },
|
|
51
|
+
{ name: 'sendgrid-key', re: /SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/ },
|
|
52
|
+
{ name: 'google-api-key', re: /AIza[0-9A-Za-z_-]{35}/ },
|
|
53
|
+
{ name: 'github-token', re: /gh[pousr]_[A-Za-z0-9]{36,}/ },
|
|
54
|
+
{ name: 'slack-token', re: /xox[baprs]-[A-Za-z0-9-]{10,}/ },
|
|
55
|
+
{ name: 'aws-access-key-id', re: /AKIA[0-9A-Z]{16}/ },
|
|
56
|
+
{ name: 'pem-private-key', re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/ },
|
|
57
|
+
{ name: 'generic-credentials-in-url', re: /\b[a-z][a-z0-9+.-]*:\/\/[A-Za-z0-9._%-]+:[^@\s'"`/]{6,}@/ },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/** Mask a matched secret so the output never prints it in full. */
|
|
61
|
+
export function redact(s: string): string {
|
|
62
|
+
if (s.length <= 6) return '******';
|
|
63
|
+
return `${s.slice(0, 4)}…${s.slice(-2)} (${s.length} chars)`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** True if an env key is client-public by Expo convention (Metro-inlined). */
|
|
67
|
+
export function isPublicEnvKey(key: string): boolean {
|
|
68
|
+
return key.startsWith('EXPO_PUBLIC_');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Scan one artifact's text for generic secret shapes. Matches are redacted. */
|
|
72
|
+
export function scanSecretShapes(text: string, source: string): AuditFinding[] {
|
|
73
|
+
const findings: AuditFinding[] = [];
|
|
74
|
+
const seen = new Set<string>(); // dedupe overlapping patterns on the same span
|
|
75
|
+
for (const { name, re } of SECRET_PATTERNS) {
|
|
76
|
+
const m = re.exec(text);
|
|
77
|
+
if (m && !seen.has(m[0])) {
|
|
78
|
+
seen.add(m[0]);
|
|
79
|
+
findings.push({
|
|
80
|
+
severity: 'fail',
|
|
81
|
+
check: 'secret-shape',
|
|
82
|
+
source,
|
|
83
|
+
detail: `${name}: ${redact(m[0])}`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return findings;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Extract client JS bundle URLs from served HTML (Expo web export convention). */
|
|
91
|
+
export function extractBundleUrls(html: string): string[] {
|
|
92
|
+
const urls = new Set<string>();
|
|
93
|
+
const re = /["'(]([^"'()]*_expo\/static\/js\/web\/[^"'()?]+\.js)/g;
|
|
94
|
+
let m: RegExpExecArray | null;
|
|
95
|
+
while ((m = re.exec(html)) !== null) {
|
|
96
|
+
urls.add(m[1].replace(/^\.?\//, ''));
|
|
97
|
+
}
|
|
98
|
+
return [...urls];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Extract served *static asset* references (images, data files, fonts, css)
|
|
103
|
+
* from a bundle/HTML — anything under `assets/` or `_expo/static/` that is not
|
|
104
|
+
* JS or a source map (those are crawled / gated separately). Used by the remote
|
|
105
|
+
* weight scan to discover what to HEAD for its size without downloading it.
|
|
106
|
+
*/
|
|
107
|
+
export function extractAssetUrls(text: string): string[] {
|
|
108
|
+
const urls = new Set<string>();
|
|
109
|
+
const re = /["'(]((?:\.?\/)?(?:assets|_expo\/static)\/[^"'()\s]+?\.[A-Za-z0-9]+)(?:\?[^"'()\s]*)?["')]/g;
|
|
110
|
+
let m: RegExpExecArray | null;
|
|
111
|
+
while ((m = re.exec(text)) !== null) {
|
|
112
|
+
const rel = m[1].replace(/^\.?\//, '');
|
|
113
|
+
if (/\.(?:js|mjs|cjs|map)$/i.test(rel)) continue;
|
|
114
|
+
urls.add(rel);
|
|
115
|
+
}
|
|
116
|
+
return [...urls];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const EXTRA_KEY_SKIP = new Set([
|
|
120
|
+
'JSON', 'URL', 'GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS', 'PATCH',
|
|
121
|
+
'API', 'HTTP', 'HTTPS', 'UTF', 'UUID', 'ID', 'OK', 'EAS',
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Best-effort extraction of ALL-CAPS env-looking keys serialized near an
|
|
126
|
+
* `"extra"` region of the bundle (where Expo embeds app.config `extra`). No EAS
|
|
127
|
+
* projectId anchor needed — we scan a window around each `"extra"` occurrence.
|
|
128
|
+
*/
|
|
129
|
+
export function extractExtraEnvKeys(text: string, window = 600): string[] {
|
|
130
|
+
const keys = new Set<string>();
|
|
131
|
+
const keyRe = /["']([A-Z][A-Z0-9_]{2,})["']\s*:/g;
|
|
132
|
+
let idx = text.indexOf('"extra"');
|
|
133
|
+
while (idx !== -1) {
|
|
134
|
+
const region = text.slice(idx, idx + window);
|
|
135
|
+
let m: RegExpExecArray | null;
|
|
136
|
+
keyRe.lastIndex = 0;
|
|
137
|
+
while ((m = keyRe.exec(region)) !== null) {
|
|
138
|
+
if (!EXTRA_KEY_SKIP.has(m[1])) keys.add(m[1]);
|
|
139
|
+
}
|
|
140
|
+
idx = text.indexOf('"extra"', idx + 1);
|
|
141
|
+
}
|
|
142
|
+
return [...keys];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Flag any non-EXPO_PUBLIC env key found embedded in the bundle's extra. */
|
|
146
|
+
export function classifyPrivateEnvKeys(keys: string[], source: string): AuditFinding[] {
|
|
147
|
+
return keys
|
|
148
|
+
.filter((k) => !isPublicEnvKey(k))
|
|
149
|
+
.map((k) => ({
|
|
150
|
+
severity: 'fail' as const,
|
|
151
|
+
check: 'private-env-key' as const,
|
|
152
|
+
source,
|
|
153
|
+
detail: `${k} is embedded in the bundle but is not EXPO_PUBLIC_* — a private value leaked`,
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Operator-run value canary: does the bundle contain the literal secret value?
|
|
159
|
+
* Returns a finding with the value REDACTED (replaced by a [KEY] marker) — the
|
|
160
|
+
* raw value is never placed in the output.
|
|
161
|
+
*/
|
|
162
|
+
export function scanSecretValue(
|
|
163
|
+
text: string,
|
|
164
|
+
key: string,
|
|
165
|
+
value: string,
|
|
166
|
+
source: string,
|
|
167
|
+
): AuditFinding | null {
|
|
168
|
+
if (isPublicEnvKey(key)) return null; // public by design
|
|
169
|
+
if (value.length < 8) return null; // too short to be a meaningful secret
|
|
170
|
+
const at = text.indexOf(value);
|
|
171
|
+
if (at === -1) return null;
|
|
172
|
+
return {
|
|
173
|
+
severity: 'fail',
|
|
174
|
+
check: 'secret-value',
|
|
175
|
+
source,
|
|
176
|
+
detail: `value of ${key} appears verbatim in the bundle (Metro-inlined leak) [${redact(value)}]`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function summarize(findings: AuditFinding[]): { fail: number; warn: number } {
|
|
181
|
+
return {
|
|
182
|
+
fail: findings.filter((f) => f.severity === 'fail').length,
|
|
183
|
+
warn: findings.filter((f) => f.severity === 'warn').length,
|
|
184
|
+
};
|
|
185
|
+
}
|