kryptheon-night 0.1.30 → 0.1.32
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/README.md +8 -0
- package/attack.js +57 -1
- package/engine.sql +286 -9
- package/finding.js +105 -11
- package/installer.js +232 -0
- package/intro.js +30 -6
- package/package.json +6 -3
- package/scan.js +17 -3
- package/schema.js +54 -12
- package/tamper.js +30 -5
package/schema.js
CHANGED
|
@@ -225,6 +225,25 @@ async function readIndexes(client, schema) {
|
|
|
225
225
|
* argument the caller chose.
|
|
226
226
|
*/
|
|
227
227
|
function guardedAtTheDoor(body, helpers) {
|
|
228
|
+
return doorOf(body, helpers).guarded;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The same reading, and one more answer: whether the only check at the door
|
|
233
|
+
* is one a caller with no role at all slips past.
|
|
234
|
+
*
|
|
235
|
+
* Found on a blind test (HarborLine): complete_automation_run opened with
|
|
236
|
+
* if public.org_role(v_org) not in ('owner', 'admin') then raise ...
|
|
237
|
+
* org_role answers with the caller's role there - and with NULL for somebody
|
|
238
|
+
* who has none. NULL NOT IN (...) is not true, so the IF never raises, and a
|
|
239
|
+
* stranger completed another organization's runs. A role helper's check is a
|
|
240
|
+
* guard only when it is written so NULL cannot pass - coalesce(...), or
|
|
241
|
+
* IS NULL OR ... - and otherwise it is said, by name, why it is not one.
|
|
242
|
+
*
|
|
243
|
+
* `helpers` are names (yes/no helpers about the caller) or { name, nullable }
|
|
244
|
+
* (a role helper, which answers NULL for a stranger).
|
|
245
|
+
*/
|
|
246
|
+
function doorOf(body, helpers) {
|
|
228
247
|
const text = String(body || '');
|
|
229
248
|
const writes = /\b(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\b/i.exec(text);
|
|
230
249
|
const returns = /\breturn\b/i.exec(text);
|
|
@@ -236,17 +255,26 @@ function guardedAtTheDoor(body, helpers) {
|
|
|
236
255
|
// never as the tail of a longer name.
|
|
237
256
|
const asks = (cond, name) => new RegExp('(^|[^\\w$"])"?' +
|
|
238
257
|
name.replace(/[$]/g, '\\$') + '"?\\s*\\(', 'i').test(cond);
|
|
258
|
+
const list = (helpers || []).map((h) => (typeof h === 'string' ? { name: h, nullable: false } : h));
|
|
259
|
+
let slips = false;
|
|
239
260
|
let found;
|
|
240
261
|
while ((found = guard.exec(text)) !== null) {
|
|
241
262
|
if (found.index > firstAct) break;
|
|
242
263
|
const cond = found[1].trim();
|
|
243
264
|
// Refusing when the check fails, not when it passes.
|
|
244
265
|
const refusesOnNo = /^not\b/i.test(cond) || /\bis\s+(not\s+true|false)\s*$/i.test(cond) || /=\s*false\s*$/i.test(cond);
|
|
245
|
-
if (
|
|
246
|
-
if (
|
|
247
|
-
|
|
266
|
+
if (refusesOnNo && list.some((h) => !h.nullable && asks(cond, h.name))) return { guarded: true, nullSlips: false };
|
|
267
|
+
if (refusesOnNo && /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(cond) && /admin|owner|member|role/i.test(cond)) {
|
|
268
|
+
return { guarded: true, nullSlips: false };
|
|
269
|
+
}
|
|
270
|
+
if (list.some((h) => h.nullable && asks(cond, h.name))) {
|
|
271
|
+
const refuses = refusesOnNo || /\bnot\s+in\s*\(/i.test(cond) || /<>|!=/.test(cond);
|
|
272
|
+
const nullSafe = /\bcoalesce\s*\(/i.test(cond) || /\bis\s+null\b/i.test(cond) || /\bis\s+not\s+true\s*$/i.test(cond);
|
|
273
|
+
if (refuses && nullSafe) return { guarded: true, nullSlips: false };
|
|
274
|
+
if (refuses) slips = true;
|
|
275
|
+
}
|
|
248
276
|
}
|
|
249
|
-
return false;
|
|
277
|
+
return { guarded: false, nullSlips: slips };
|
|
250
278
|
}
|
|
251
279
|
|
|
252
280
|
async function readAnonDefinerFunctions(client, schema, role) {
|
|
@@ -262,6 +290,7 @@ async function readAnonDefinerFunctions(client, schema, role) {
|
|
|
262
290
|
p.proconfig::text AS config,
|
|
263
291
|
pg_get_functiondef(p.oid) AS def,
|
|
264
292
|
p.prorettype = 'pg_catalog.bool'::regtype AS returns_bool,
|
|
293
|
+
(SELECT t.typtype = 'e' FROM pg_type t WHERE t.oid = p.prorettype) AS returns_enum,
|
|
265
294
|
${exists.rows.length ? "has_function_privilege($2, p.oid, 'EXECUTE')" : 'false'} AS callable
|
|
266
295
|
FROM pg_proc p
|
|
267
296
|
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
@@ -271,21 +300,25 @@ async function readAnonDefinerFunctions(client, schema, role) {
|
|
|
271
300
|
ORDER BY p.proname`,
|
|
272
301
|
exists.rows.length ? [schema, caller] : [schema],
|
|
273
302
|
);
|
|
274
|
-
// The schema's
|
|
275
|
-
//
|
|
276
|
-
// call either. Read once, for
|
|
303
|
+
// The schema's helpers about the caller - is_org_admin(org) asking whether
|
|
304
|
+
// auth.uid() is an admin of it, org_role(org) answering with their role -
|
|
305
|
+
// definer or not, since a guard can call either. Read once, for doorOf. A
|
|
306
|
+
// role helper answers NULL for somebody with no role, so it is marked.
|
|
277
307
|
const { rows: yesNo } = await client.query(
|
|
278
|
-
`SELECT p.proname AS name, pg_get_functiondef(p.oid) AS def
|
|
308
|
+
`SELECT p.proname AS name, pg_get_functiondef(p.oid) AS def,
|
|
309
|
+
p.prorettype <> 'pg_catalog.bool'::regtype AS nullable
|
|
279
310
|
FROM pg_proc p
|
|
280
311
|
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
281
|
-
|
|
312
|
+
JOIN pg_type t ON t.oid = p.prorettype
|
|
313
|
+
WHERE n.nspname = $1 AND p.prokind = 'f' AND (p.prorettype = 'pg_catalog.bool'::regtype OR t.typtype = 'e')
|
|
314
|
+
ORDER BY p.proname`,
|
|
282
315
|
[schema],
|
|
283
316
|
);
|
|
284
317
|
const helpers = yesNo.filter((row) => {
|
|
285
318
|
const body = String(row.def || '').replace(/--[^\n]*/g, ' ');
|
|
286
319
|
return /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(body) &&
|
|
287
320
|
!/\b(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\b/i.test(body);
|
|
288
|
-
}).map((row) => row.name);
|
|
321
|
+
}).map((row) => ({ name: row.name, nullable: row.nullable === true }));
|
|
289
322
|
return rows.map((row) => {
|
|
290
323
|
// A definer function with no search_path pinned is a second, separate
|
|
291
324
|
// hazard: the caller can set their own search_path and make the function
|
|
@@ -309,13 +342,21 @@ async function readAnonDefinerFunctions(client, schema, role) {
|
|
|
309
342
|
// else and writing nothing, they are not a reach. A yes/no that does not
|
|
310
343
|
// ask auth.uid() - is_admin(user_id) - answers about other people, and
|
|
311
344
|
// stays a finding.
|
|
312
|
-
|
|
345
|
+
//
|
|
346
|
+
// A role about the caller alone is the same: org_role(org), the caller's
|
|
347
|
+
// own role there, from a fixed list of labels (an enum) that can hold
|
|
348
|
+
// nothing about anybody else. Found on a blind test (HarborLine), where
|
|
349
|
+
// org_role was reported beside the functions it guards.
|
|
350
|
+
const aboutCaller = (row.returns_bool === true || row.returns_enum === true) && !writes &&
|
|
351
|
+
/\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(body);
|
|
352
|
+
const door = doorOf(body, helpers);
|
|
313
353
|
return {
|
|
314
354
|
name: row.name,
|
|
315
355
|
args: row.args || '',
|
|
316
356
|
writes: writes,
|
|
317
357
|
aboutCaller: aboutCaller,
|
|
318
|
-
guarded:
|
|
358
|
+
guarded: door.guarded,
|
|
359
|
+
nullSlips: door.nullSlips,
|
|
319
360
|
hasFixedSearchPath: hasFixedSearchPath,
|
|
320
361
|
callable: row.callable === true,
|
|
321
362
|
};
|
|
@@ -1438,6 +1479,7 @@ module.exports = {
|
|
|
1438
1479
|
readSchema: readSchema,
|
|
1439
1480
|
readAnonDefinerFunctions: readAnonDefinerFunctions,
|
|
1440
1481
|
guardedAtTheDoor: guardedAtTheDoor,
|
|
1482
|
+
doorOf: doorOf,
|
|
1441
1483
|
writeSchema: writeSchema,
|
|
1442
1484
|
diffSchemas: diffSchemas,
|
|
1443
1485
|
readPolicies: readPolicies,
|
package/tamper.js
CHANGED
|
@@ -114,6 +114,11 @@ async function tamper(client, schema, tables, seeded, policies) {
|
|
|
114
114
|
rulesFor.set(policy.table_name, list);
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
// The first person's row in tables that hold one for each person but name
|
|
118
|
+
// nobody (attack.seed), so a write is aimed at a row that is not the
|
|
119
|
+
// caller's own.
|
|
120
|
+
const firstRowOf = new Map((seeded || []).filter((entry) => entry.rowOfA).map((entry) => [entry.table, entry.rowOfA]));
|
|
121
|
+
|
|
117
122
|
for (const table of tables) {
|
|
118
123
|
if (!shapeFor.has(table.name)) continue; // never seeded, so nothing to protect
|
|
119
124
|
const owner = attack.ownerColumn(table);
|
|
@@ -127,9 +132,12 @@ async function tamper(client, schema, tables, seeded, policies) {
|
|
|
127
132
|
// Rows belonging to the other fake person. Scoped on purpose: a signed-in
|
|
128
133
|
// customer deleting their OWN rows is not a finding, it is the feature,
|
|
129
134
|
// and an unscoped DELETE would report every correctly built app.
|
|
135
|
+
const firstRow = firstRowOf.get(table.name);
|
|
130
136
|
const theirRows = owner
|
|
131
137
|
? ' WHERE ' + quote(owner) + ' = ' + "'" + attack.USER_A + "'"
|
|
132
|
-
:
|
|
138
|
+
: firstRow
|
|
139
|
+
? ' WHERE ' + quote(firstRow.column) + "::text = '" + firstRow.value.split("'").join("''") + "'"
|
|
140
|
+
: '';
|
|
133
141
|
const at = quote(schema) + '.' + quote(table.name);
|
|
134
142
|
|
|
135
143
|
// Written under a name nobody has used, which is both what makes the
|
|
@@ -334,7 +342,8 @@ function memberships(tables) {
|
|
|
334
342
|
}
|
|
335
343
|
|
|
336
344
|
// What each kind of write is called in a rule.
|
|
337
|
-
|
|
345
|
+
// A takeover is an UPDATE too: a rule for updating is what it would hide behind.
|
|
346
|
+
const COMMAND = { add: 'INSERT', change: 'UPDATE', delete: 'DELETE', takeover: 'UPDATE' };
|
|
338
347
|
|
|
339
348
|
/**
|
|
340
349
|
* Whether the app's own rules name this role among those allowed to make
|
|
@@ -478,6 +487,22 @@ async function teammate(client, schema, tables, seeded, rules) {
|
|
|
478
487
|
{ what: 'change', statement: 'UPDATE ' + target + ' SET ' + column + ' = ' + column + notTheirs, values: [] },
|
|
479
488
|
{ what: 'delete', statement: 'DELETE FROM ' + target + notTheirs, values: [] },
|
|
480
489
|
];
|
|
490
|
+
// On the members table itself: putting their own id on the first
|
|
491
|
+
// person's membership row - taking that member's place, and their role.
|
|
492
|
+
// Found on a blind test (HarborLine): a member could update any member
|
|
493
|
+
// row of the team, and repointing an admin's row made them admin. A rule
|
|
494
|
+
// that lets a role edit memberships does not make this the design, so
|
|
495
|
+
// it is asked of every role, listed or not.
|
|
496
|
+
if (table.name === member.table.name) {
|
|
497
|
+
const firstPersons = [quote(member.person) + " = '" + attack.USER_A + "'"]
|
|
498
|
+
.concat(Object.keys(pointed).map((name) => quote(name) + " = '" + String(pointed[name]).split("'").join("''") + "'"));
|
|
499
|
+
moves.push({
|
|
500
|
+
what: 'takeover',
|
|
501
|
+
anyRole: true,
|
|
502
|
+
statement: 'UPDATE ' + target + ' SET ' + quote(member.person) + " = '" + attack.USER_B + "' WHERE " + firstPersons.join(' AND '),
|
|
503
|
+
values: [],
|
|
504
|
+
});
|
|
505
|
+
}
|
|
481
506
|
// Adding a row to the team, said only of a role whose own name says it
|
|
482
507
|
// may only look: a member adding rows is the feature, a viewer adding
|
|
483
508
|
// them is not. Found on a blind test (HelixOps): any member could add
|
|
@@ -534,7 +559,7 @@ async function teammate(client, schema, tables, seeded, rules) {
|
|
|
534
559
|
// A write the rules list this role for is not asked: whatever it
|
|
535
560
|
// did would not be reported, and a key refusing it on the way out
|
|
536
561
|
// left the table untested (HelixOps' assets, as a dispatcher).
|
|
537
|
-
if (allowedByRules(rules, table.name, move.what, role, member.roleType)) {
|
|
562
|
+
if (!move.anyRole && allowedByRules(rules, table.name, move.what, role, member.roleType)) {
|
|
538
563
|
listed = true;
|
|
539
564
|
continue;
|
|
540
565
|
}
|
|
@@ -554,7 +579,7 @@ async function teammate(client, schema, tables, seeded, rules) {
|
|
|
554
579
|
}
|
|
555
580
|
completed.push(key);
|
|
556
581
|
if (can.length) {
|
|
557
|
-
findings.push({
|
|
582
|
+
findings.push(Object.assign({
|
|
558
583
|
kind: 'role',
|
|
559
584
|
table: table.name,
|
|
560
585
|
who: role,
|
|
@@ -566,7 +591,7 @@ async function teammate(client, schema, tables, seeded, rules) {
|
|
|
566
591
|
below: below.slice(),
|
|
567
592
|
columns: (table.columns || []).map((c) => c.name),
|
|
568
593
|
rlsEnabled: table.rlsEnabled,
|
|
569
|
-
});
|
|
594
|
+
}, can.includes('takeover') ? { takeover: true, personColumn: member.person } : {}));
|
|
570
595
|
break;
|
|
571
596
|
}
|
|
572
597
|
if (!listed) below.push(role);
|