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/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 (!refusesOnNo) continue;
246
- if ((helpers || []).some((name) => asks(cond, name))) return true;
247
- if (/\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(cond) && /admin|owner|member|role/i.test(cond)) return true;
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 yes/no helpers about the caller - is_org_admin(org) asking
275
- // whether auth.uid() is an admin of it - definer or not, since a guard can
276
- // call either. Read once, for guardedAtTheDoor.
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
- WHERE n.nspname = $1 AND p.prokind = 'f' AND p.prorettype = 'pg_catalog.bool'::regtype`,
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
- const aboutCaller = row.returns_bool === true && !writes && /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(body);
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: guardedAtTheDoor(body, helpers),
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
- const COMMAND = { add: 'INSERT', change: 'UPDATE', delete: 'DELETE' };
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);