kryptheon-night 0.1.25 → 0.1.27

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/scan.js CHANGED
@@ -128,7 +128,7 @@ function suggest(schemas) {
128
128
  }
129
129
 
130
130
  /**
131
- * The engine, as six operations, so the scan does not know which one it has.
131
+ * The engine, as seven operations, so the scan does not know which one it has.
132
132
  *
133
133
  * `scan.js` named `schema.js`, `attack.js`, `tamper.js` and `orphan.js`
134
134
  * directly, which meant the SQL engine could only ever be reached by the twin
@@ -153,6 +153,7 @@ function nodeEngine() {
153
153
  tamper: (client, into, tables, seeded, policies) =>
154
154
  tamper.tamper(client, into, tables, seeded, policies),
155
155
  orphan: (client, into, tables, seeded) => orphan.orphan(client, into, tables, seeded),
156
+ teammate: (client, into, tables, seeded) => tamper.teammate(client, into, tables, seeded),
156
157
  };
157
158
  }
158
159
 
@@ -302,6 +303,14 @@ async function scan(client, sourceSchema, options) {
302
303
  notChecked.push({ table: stuck.table, key: stuck.key, why: stuck.why });
303
304
  }
304
305
 
306
+ // What the least trusted member of a team can change there. Rolled back
307
+ // like the writes above, and the joining with them.
308
+ const ranked = await engine.teammate(client, copyName, theirs, sown.seeded);
309
+ for (const key of ranked.completed) attempted.push(key);
310
+ for (const stuck of ranked.blocked) {
311
+ notChecked.push({ table: stuck.table, key: stuck.key, why: stuck.why });
312
+ }
313
+
305
314
  // Can a half-finished write survive? Rolled back like the writes above.
306
315
  say(' Looking for rows that could point at nothing ...');
307
316
  const stranded = await engine.orphan(client, copyName, theirs, sown.seeded);
@@ -395,18 +404,38 @@ async function scan(client, sourceSchema, options) {
395
404
  for (const fn of plan.anonFunctions || []) attempted.push('privileged:' + fn.name);
396
405
  const privileged = privilegedOf(plan);
397
406
 
407
+ // Storage buckets switched to public that also have a rule saying who may
408
+ // read them. Read from the bucket settings and the rules, never a file.
409
+ // Every bucket read counts as examined, so one made private comes back
410
+ // from a re-check as fixed.
411
+ for (const bucket of plan.buckets || []) attempted.push('bucket:' + bucket.name);
412
+ const buckets = bucketsOf(plan);
413
+
398
414
  // Ties read from the original schema: the copy points auth.users keys at
399
415
  // a stand-in, and would call every table untied. Owners read from the
400
416
  // copy, the way seeding did.
401
417
  const tagged = taggerFor(plan, theirs);
402
418
 
419
+ // Where this run was, so a re-check can tell a database it saw before from
420
+ // another one entirely (recheck.elsewhereOf). The name and address only.
421
+ const at = (await client.query(
422
+ 'SELECT current_database() AS database, host(inet_server_addr()) AS host, inet_server_port() AS port',
423
+ )).rows[0];
424
+
403
425
  return {
404
426
  stopped: null,
427
+ where: {
428
+ database: at.database,
429
+ host: at.host || null,
430
+ port: at.port || null,
431
+ schema: sourceSchema,
432
+ tables: plan.tables.map((table) => table.name).sort(),
433
+ },
405
434
  attacksRun: attempted.length,
406
435
  notChecked: notChecked,
407
436
  attempted: attempted,
408
437
  findings: finding.describeAll(
409
- impersonation.findings.concat(writes.findings, stranded.findings, raced, privileged, recursive).map(tagged),
438
+ impersonation.findings.concat(writes.findings, ranked.findings, stranded.findings, raced, privileged, buckets, recursive).map(tagged),
410
439
  ),
411
440
  };
412
441
  } finally {
@@ -477,6 +506,37 @@ function privilegedOf(plan) {
477
506
  }));
478
507
  }
479
508
 
509
+ /**
510
+ * A public bucket whose read rule says it was meant to be private.
511
+ *
512
+ * Found on two blind tests (OrbitDesk, AtlasPay): a bucket of exports and one
513
+ * of settlement evidence were switched to public. A public bucket hands any
514
+ * file to whoever has its link, and the rules on storage.objects are not asked.
515
+ * A rule that only lets in some people is how the app says the files are not
516
+ * for everyone - so a public bucket with one is worth a look. A rule that lets
517
+ * in everyone anyway (anon or public, and nothing but the bucket's name) says
518
+ * the opposite, and is not reported.
519
+ */
520
+ function bucketsOf(plan) {
521
+ const escape = (x) => String(x).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
522
+ const narrows = (rule, id) => {
523
+ const roles = schema.roleList(rule.roles);
524
+ const toEveryone = roles.includes('anon') || roles.includes('public');
525
+ const rest = String(rule.qual || '')
526
+ .replace(new RegExp("bucket_id\\s*=\\s*'" + escape(id) + "'(::text)?", 'gi'), '')
527
+ .replace(new RegExp("'" + escape(id) + "'(::text)?\\s*=\\s*bucket_id", 'gi'), '')
528
+ .replace(/\btrue\b|\band\b|[()\s]/gi, '');
529
+ return !toEveryone || rest !== '';
530
+ };
531
+ return ((plan && plan.buckets) || []).filter((b) => b.public).map((b) => ({
532
+ kind: 'bucket',
533
+ table: b.name,
534
+ bucket: b.id,
535
+ rules: (b.readRules || []).filter((rule) => narrows(rule, b.id)).map((rule) => rule.name),
536
+ columns: [],
537
+ })).filter((f) => f.rules.length);
538
+ }
539
+
480
540
  /**
481
541
  * What each finding needs to know about its table for its fix to be right:
482
542
  * whether the app signs people in through Supabase, whether a row says whose
@@ -567,7 +627,7 @@ function nightlyRecord(latest, readAt, plan) {
567
627
  const at = f.kind === 'recursive' && !f.table ? (loops.length ? loops : ['one of your tables']) : [f.table];
568
628
  for (const table of at) night.push(tag(Object.assign({}, f, { table: table, fromNight: true })));
569
629
  }
570
- const now = plan ? privilegedOf(plan).map(tag) : [];
630
+ const now = plan ? privilegedOf(plan).concat(bucketsOf(plan)).map(tag) : [];
571
631
  return Object.assign(record, {
572
632
  ranAt: at(run.ran_at),
573
633
  source: run.source,
@@ -737,8 +797,14 @@ function report(result) {
737
797
  line(' ' + confirmed.length + (confirmed.length === 1 ? ' problem' : ' problems') + ' found.');
738
798
  }
739
799
  if (toVerify.length) {
740
- line(' ' + toVerify.length + ' thing' + (toVerify.length === 1 ? '' : 's') +
741
- ' to check - I did not attack ' + (toVerify.length === 1 ? 'it' : 'them') + ', only spotted the risk.');
800
+ // A viewer who can edit was really tried; a function or a bucket was only
801
+ // read. Either way it is the owner's call, and the line says which.
802
+ const tried = toVerify.some((f) => f.kind === 'role' || f.kind === 'teamread');
803
+ const spotted = toVerify.some((f) => f.kind !== 'role' && f.kind !== 'teamread');
804
+ const it = toVerify.length === 1 ? 'it' : 'them';
805
+ line(' ' + toVerify.length + ' thing' + (toVerify.length === 1 ? '' : 's') + ' to check - ' +
806
+ (spotted && !tried ? 'I did not attack ' + it + ', only spotted the risk.'
807
+ : 'whether ' + (toVerify.length === 1 ? 'it is a problem' : 'each is a problem') + ' is your decision.'));
742
808
  }
743
809
 
744
810
  for (const item of result.findings) {
@@ -859,7 +925,9 @@ async function main() {
859
925
  if (result.findings.length) report(result);
860
926
  else notTestedLines(result).forEach(line);
861
927
  saveRun(file, result);
862
- process.exitCode = result.stopped ? 2 : verdict.allClear ? 0 : 1;
928
+ // Two different databases: the verdict is a comparison, so the exit code
929
+ // is this run's own answer, the same as a first run.
930
+ process.exitCode = result.stopped ? 2 : verdict.elsewhere ? exitCodeFor(result) : verdict.allClear ? 0 : 1;
863
931
  } finally {
864
932
  await client.end();
865
933
  }
@@ -887,6 +955,7 @@ module.exports = {
887
955
  scheduleTimes: scheduleTimes,
888
956
  notCheckedFromNight: notCheckedFromNight,
889
957
  privilegedOf: privilegedOf,
958
+ bucketsOf: bucketsOf,
890
959
  loopingTables: loopingTables,
891
960
  taggerFor: taggerFor,
892
961
  saveNightly: saveNightly,
package/schema.js CHANGED
@@ -356,6 +356,48 @@ async function readFunctions(client, schema) {
356
356
  return rows;
357
357
  }
358
358
 
359
+ /**
360
+ * Supabase Storage's buckets: whether each is public, and the rules on
361
+ * storage.objects that say who may read it (a rule naming the bucket's id).
362
+ *
363
+ * The settings and the rules only - never a file, never a file's name. A
364
+ * database without Supabase Storage, or one this connection cannot read it in,
365
+ * has none. Mirrors read_buckets in engine.sql, query for query.
366
+ *
367
+ * Asked first rather than left to fail: a failed query inside a caller's
368
+ * transaction would abort everything after it.
369
+ */
370
+ async function readBuckets(client) {
371
+ try {
372
+ const here = await client.query(
373
+ `SELECT to_regclass('storage.buckets') IS NOT NULL
374
+ AND EXISTS (SELECT 1 FROM information_schema.columns
375
+ WHERE table_schema = 'storage' AND table_name = 'buckets' AND column_name = 'public') AS found`,
376
+ );
377
+ if (!here.rows[0].found) return [];
378
+ const { rows } = await client.query(BUCKETS_QUERY);
379
+ return rows[0].buckets;
380
+ } catch (err) {
381
+ return [];
382
+ }
383
+ }
384
+
385
+ const BUCKETS_QUERY =
386
+ `SELECT coalesce(jsonb_agg(jsonb_build_object(
387
+ 'id', b.id::text,
388
+ 'name', b.name::text,
389
+ 'public', coalesce(b.public, false),
390
+ 'readRules', (SELECT coalesce(jsonb_agg(jsonb_build_object(
391
+ 'name', p.policyname::text,
392
+ 'roles', to_jsonb(p.roles::text[]),
393
+ 'qual', p.qual) ORDER BY p.policyname), '[]'::jsonb)
394
+ FROM pg_policies p
395
+ WHERE p.schemaname = 'storage' AND p.tablename = 'objects'
396
+ AND p.cmd IN ('SELECT', 'ALL')
397
+ AND position(quote_literal(b.id::text) IN coalesce(p.qual, '')) > 0)
398
+ ) ORDER BY b.id), '[]'::jsonb) AS buckets
399
+ FROM storage.buckets b`;
400
+
359
401
  async function readPolicies(client, schema) {
360
402
  const { rows } = await client.query(
361
403
  `SELECT tablename AS table_name,
@@ -632,6 +674,7 @@ async function readSchema(client, schema) {
632
674
  external: external,
633
675
  anonFunctions: await readAnonDefinerFunctions(client, schema),
634
676
  functions: await readFunctions(client, schema),
677
+ buckets: await readBuckets(client),
635
678
  unsupported: unsupported,
636
679
  };
637
680
  }
@@ -764,11 +807,26 @@ function copyFunctionStatement(fn, plan, target, copiedNames) {
764
807
  () => 'CREATE FUNCTION ' + quote(target) + '.',
765
808
  );
766
809
  // The settings line, e.g. SET search_path TO 'public', 'storage'
810
+ const pinned = /^\s*SET search_path (?:TO|=) /m.test(def);
767
811
  def = def.replace(/^(\s*SET search_path (?:TO|=) )(.*)$/m, (all, head, list) =>
768
812
  head + list.split(',').map((part) => {
769
813
  const bare = part.trim().replace(/^'(.*)'$/, '$1').replace(/^"(.*)"$/, '$1');
770
814
  return bare === plan.schema ? "'" + target + "', " + part.trim() : part.trim();
771
815
  }).join(', '));
816
+ // None pinned, so a bare name in the body is looked up on the caller's path
817
+ // when it runs - and the caller's path has never heard of the copy. Found on
818
+ // a blind test (OrbitDesk): member_of and has_role read `workspace_members`
819
+ // bare, so the copy's helpers read the original's members, with the
820
+ // original's workspace_role, against the copy's. Every rule calling has_role
821
+ // failed with "operator does not exist", 13 attacks never ran, and a loop
822
+ // through member_of never fired because the original's table was empty.
823
+ // So the copy's helper is pinned the way the copy itself was built: the copy
824
+ // first, then where the original's bare names would have gone. A BEGIN
825
+ // ATOMIC body has no AS line and needs none - it was bound when it was made.
826
+ if (!pinned) {
827
+ const path = Array.from(new Set([target, plan.schema, 'public', 'extensions']));
828
+ def = def.replace(/\nAS /, '\n SET search_path TO ' + path.map((s) => "'" + s + "'").join(', ') + '\nAS ');
829
+ }
772
830
  return rewriteOwnTableRefs(def, plan, target, copiedNames);
773
831
  }
774
832
 
@@ -1383,6 +1441,7 @@ module.exports = {
1383
1441
  writeSchema: writeSchema,
1384
1442
  diffSchemas: diffSchemas,
1385
1443
  readPolicies: readPolicies,
1444
+ readBuckets: readBuckets,
1386
1445
  readTypes: readTypes,
1387
1446
  readSequenceGrants: readSequenceGrants,
1388
1447
  readIndexes: readIndexes,
package/sqlengine.js CHANGED
@@ -114,6 +114,15 @@ async function tamper(client, target, into, tables, seeded, policies) {
114
114
  return rows[0].answer;
115
115
  }
116
116
 
117
+ /** What the least trusted member of a team can change. Rolled back inside the engine. */
118
+ async function teammate(client, target, into, tables, seeded) {
119
+ const { rows } = await client.query(
120
+ 'SELECT ' + quote(target) + '.teammate($1, $2::jsonb, $3::jsonb) AS answer',
121
+ [into, JSON.stringify(tables), JSON.stringify(seeded)],
122
+ );
123
+ return rows[0].answer;
124
+ }
125
+
117
126
  /** Can a half-finished row survive? */
118
127
  async function orphan(client, target, into, tables, seeded) {
119
128
  const { rows } = await client.query(
@@ -141,7 +150,7 @@ async function collide(client, target, into, tables, indexes) {
141
150
  }
142
151
 
143
152
  /**
144
- * The same six operations `scan.js` asks of an engine, done in SQL.
153
+ * The same seven operations `scan.js` asks of an engine, done in SQL.
145
154
  *
146
155
  * Handed an engine schema that is already installed, because installing and
147
156
  * removing it is the caller's business - `withEngine` below - and a scan
@@ -162,6 +171,7 @@ function adapterFor(target) {
162
171
  tamper: (client, into, tables, sown, policies) =>
163
172
  tamper(client, target, into, tables, sown, policies),
164
173
  orphan: (client, into, tables, sown) => orphan(client, target, into, tables, sown),
174
+ teammate: (client, into, tables, sown) => teammate(client, target, into, tables, sown),
165
175
  };
166
176
  }
167
177
 
@@ -194,6 +204,7 @@ module.exports = {
194
204
  impersonate: impersonate,
195
205
  tamper: tamper,
196
206
  orphan: orphan,
207
+ teammate: teammate,
197
208
  collide: collide,
198
209
  adapterFor: adapterFor,
199
210
  withEngine: withEngine,
package/tamper.js CHANGED
@@ -47,9 +47,13 @@ const ACTORS = [
47
47
  * The rollback is not a tidy-up, it is the safety property. Nothing this
48
48
  * attack does survives the statement that did it.
49
49
  */
50
- async function tryWrite(client, role, identity, statement, values) {
50
+ async function tryWrite(client, role, identity, statement, values, first) {
51
51
  await client.query('BEGIN');
52
52
  try {
53
+ // Something the copy's owner does first, inside the same transaction, so
54
+ // it is undone with the write: putting a person into a team for one
55
+ // attempt (see teammate below).
56
+ if (first) await client.query(first.statement, first.values || []);
53
57
  await client.query("SET LOCAL statement_timeout = '15s'");
54
58
  await client.query('SET LOCAL role TO ' + role);
55
59
  await client.query('SELECT set_config($1, $2, true)', [
@@ -217,6 +221,329 @@ function firstWritable(table) {
217
221
  return (plain || usable[0] || { name: 'id' }).name;
218
222
  }
219
223
 
224
+ /*
225
+ * What the least trusted member of a team can change.
226
+ *
227
+ * Everything above is one customer against another. Found on two blind tests
228
+ * (OrbitDesk, AtlasPay): a rule written "members of this workspace may change
229
+ * it" lets a viewer change it too, and an UPDATE rule that checks the role is
230
+ * undone by a second one beside it that does not - Postgres lets a write
231
+ * through if any permissive rule allows it. Five planted holes of this shape
232
+ * went unreported, because both fake people were the owner of their own team
233
+ * and nobody else was ever in it.
234
+ *
235
+ * So one of them joins the other's team with the lowest role there is, for
236
+ * one attempt at a time, and tries to change what is in it. Whether a viewer
237
+ * may edit is the owner's decision, so this is reported as something to
238
+ * check, never as a confirmed break.
239
+ */
240
+
241
+ // Labels that say "may only look", tried in this order before falling back
242
+ // to the last label that is not plainly in charge.
243
+ const LEAST_TRUSTED = [/view/i, /read/i, /guest/i, /observ/i, /audit/i];
244
+ const IN_CHARGE = /owner|admin|super|root/i;
245
+ // Owner columns that name who made a row rather than who is in it.
246
+ const CREATOR = /^(created_by|author_id|owner|owner_id)$/i;
247
+ // Columns that hold something a member should not simply be able to read:
248
+ // a token, a secret or a key, a password, or the payload a webhook carried.
249
+ // Found on a blind test (HelixOps): integration_tokens.token_value and
250
+ // webhook_deliveries.payload readable by every member, and api_clients
251
+ // (client_key, secret_hash) by a dispatcher. A word on its own between
252
+ // underscores, so tokens - a count - and question_key are not taken.
253
+ // AtlasPay's webhook deliveries kept theirs as request_body and response_body.
254
+ const SECRET_COLUMN = /(^|_)(token|secret|password|passwd|pwd|payload|credentials?|(api|private|access|client|signing|service|encryption|secret|license)_?key|(request|response)_body)(_|$)/i;
255
+
256
+ /** The columns of a table that look like they hold a secret. */
257
+ function secretColumns(table) {
258
+ return (table.columns || []).map((c) => c.name).filter((name) => SECRET_COLUMN.test(name));
259
+ }
260
+
261
+ /** Whether a role's own name says it may only look. */
262
+ function lookOnly(role) {
263
+ return LEAST_TRUSTED.some((pattern) => pattern.test(role));
264
+ }
265
+
266
+ /** The role a viewer has, out of a team's list of roles, or null. */
267
+ function lowestRole(labels) {
268
+ for (const pattern of LEAST_TRUSTED) {
269
+ const found = labels.find((label) => pattern.test(label));
270
+ if (found) return found;
271
+ }
272
+ const plain = labels.filter((label) => !IN_CHARGE.test(label));
273
+ return plain.length ? plain[plain.length - 1] : null;
274
+ }
275
+
276
+ /**
277
+ * The column holding a member's role, and its lowest value: named for a role,
278
+ * with a fixed list of values (an enum, or a CHECK listing them).
279
+ */
280
+ function roleColumnOf(table) {
281
+ for (const column of table.columns || []) {
282
+ if (!/role/i.test(column.name)) continue;
283
+ const labels = Array.isArray(column.enum_labels) && column.enum_labels.length
284
+ ? column.enum_labels
285
+ : attack.allowedByCheck(table, column.name);
286
+ if (labels.length < 2) continue;
287
+ const lowest = lowestRole(labels);
288
+ // Every role not plainly in charge, the lowest first and then upwards:
289
+ // the order reads are tried in, so the least trusted one that can read a
290
+ // secret is the one named.
291
+ const ladder = [lowest].concat(labels.filter((label) => label !== lowest && !IN_CHARGE.test(label)).reverse());
292
+ if (lowest) return { column: column.name, lowest: lowest, ladder: ladder };
293
+ }
294
+ return null;
295
+ }
296
+
297
+ /** Whether a column is a key on its own: one row per person, at most. */
298
+ function aloneUnique(table, column) {
299
+ return (table.constraints || []).some((constraint) => {
300
+ if (constraint.kind !== 'p' && constraint.kind !== 'u') return false;
301
+ const match = /(?:PRIMARY KEY|UNIQUE) \(([^)]+)\)/i.exec(String(constraint.definition || ''));
302
+ if (!match) return false;
303
+ const columns = match[1].split(',').map((name) => name.trim().split('"').join(''));
304
+ return columns.length === 1 && columns[0] === column;
305
+ });
306
+ }
307
+
308
+ /**
309
+ * The tables saying who is in which team, and as what: a person, a role, and
310
+ * a key to a table of the app's own that is not the person. A table where the
311
+ * person can appear only once (profiles.org_id) is not one: nobody joins a
312
+ * second team there, so there is no one to add.
313
+ */
314
+ function memberships(tables) {
315
+ const found = [];
316
+ const names = new Set((tables || []).map((table) => table.name));
317
+ for (const table of tables || []) {
318
+ const person = attack.ownerColumn(table);
319
+ // Whoever made the row is not who is in the team. Found on a blind test:
320
+ // workspace_invites (created_by, role, workspace_id) was taken for the
321
+ // members table, and an invitation made nobody a member of anything.
322
+ if (!person || CREATOR.test(person)) continue;
323
+ const role = roleColumnOf(table);
324
+ if (!role) continue;
325
+ const keys = attack.foreignKeys(table).concat(attack.impliedKeys(table, tables))
326
+ .filter((key) => key.refTable !== table.name && names.has(key.refTable) && !key.columns.includes(person));
327
+ if (!keys.length || aloneUnique(table, person)) continue;
328
+ found.push({ table: table, person: person, role: role.column, lowest: role.lowest, ladder: role.ladder, keys: keys });
329
+ }
330
+ // A table named for members first: it is the one a team rule asks.
331
+ const named = (m) => (/member/i.test(m.table.name) ? 0 : 1);
332
+ return found.map((m, i) => [m, i]).sort((a, b) => named(a[0]) - named(b[0]) || a[1] - b[1]).map((pair) => pair[0]);
333
+ }
334
+
335
+ /**
336
+ * For every team table: can a viewer change it?
337
+ *
338
+ * The other fake person joins the first one's team as its lowest role, and
339
+ * every write is tried twice - before joining and after - so only what joining
340
+ * added is counted: a hole open to every customer is already reported above,
341
+ * and a person changing their own rows is the feature. Both rolled back, the
342
+ * joining with them.
343
+ */
344
+ async function teammate(client, schema, tables, seeded) {
345
+ const findings = [];
346
+ const completed = [];
347
+ const blocked = [];
348
+ const shapeFor = new Map((seeded || []).map((entry) => [entry.table, entry.attempt || 0]));
349
+ const done = new Set();
350
+ // What a write or a read achieved, or null when it decided nothing.
351
+ const counted = (result) => {
352
+ const outcome = whatHappened(result);
353
+ return outcome === 'untested' ? null : outcome === 'got through' ? result.count : 0;
354
+ };
355
+
356
+ for (const member of memberships(tables)) {
357
+ if (!shapeFor.has(member.table.name)) continue;
358
+ const at = quote(schema) + '.' + quote(member.table.name);
359
+ let theirs = null;
360
+ try {
361
+ theirs = (await client.query('SELECT * FROM ' + at + ' WHERE ' + quote(member.person) + ' = $1 LIMIT 1',
362
+ [attack.USER_A])).rows[0] || null;
363
+ } catch (err) {
364
+ theirs = null;
365
+ }
366
+ if (!theirs) continue;
367
+
368
+ // Their team, as the first person's own membership names it.
369
+ const team = {};
370
+ for (const key of member.keys) {
371
+ key.columns.forEach((name) => { if (name !== member.person) team[name] = theirs[name]; });
372
+ }
373
+ const parentKey = member.keys[0];
374
+ const parent = parentKey.refTable;
375
+ // Putting the other person in that team, as one role. Built once a role.
376
+ const joins = new Map();
377
+ const joinAs = async (role) => {
378
+ if (joins.has(role)) return joins.get(role);
379
+ const overrides = Object.assign({}, team);
380
+ overrides[member.role] = role;
381
+ const row = await attack.rowFor(client, schema, member.table, attack.USER_B, 9, overrides, shapeFor.get(member.table.name));
382
+ const join = {
383
+ statement: 'INSERT INTO ' + at + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' +
384
+ row.values.map((_, i) => '$' + (i + 1)).join(', ') + ')',
385
+ values: row.values,
386
+ };
387
+ joins.set(role, join);
388
+ return join;
389
+ };
390
+ const as = (role) => 'as a "' + role + '" in ' + member.table.name;
391
+ let join = null;
392
+ let refused = null;
393
+ try {
394
+ join = await joinAs(member.lowest);
395
+ // Whether they can be put in the team at all, tried once on its own.
396
+ await client.query('BEGIN');
397
+ try {
398
+ await client.query(join.statement, join.values);
399
+ } finally {
400
+ await client.query('ROLLBACK').catch(() => {});
401
+ }
402
+ } catch (err) {
403
+ refused = err.message;
404
+ }
405
+ if (refused) {
406
+ // Said once, on the table that would not take them: every team table
407
+ // went untested for the same reason.
408
+ blocked.push({ table: member.table.name, key: 'role:' + member.table.name + ':' + member.lowest,
409
+ why: as(member.lowest) + ': I could not add one: ' + refused });
410
+ continue;
411
+ }
412
+
413
+ for (const table of tables) {
414
+ if (!shapeFor.has(table.name) || done.has(table.name)) continue;
415
+ done.add(table.name);
416
+ const key = 'role:' + table.name + ':' + member.lowest;
417
+ const owner = attack.ownerColumn(table);
418
+ // A table that names the team itself, and the team it is to name.
419
+ const toTeam = attack.foreignKeys(table).concat(attack.impliedKeys(table, tables))
420
+ .find((k) => k.refTable === parent && k.columns.length === parentKey.refColumns.length);
421
+ const pointed = {};
422
+ if (toTeam) {
423
+ toTeam.columns.forEach((name, i) => {
424
+ pointed[name] = team[parentKey.columns[parentKey.refColumns.indexOf(toTeam.refColumns[i])]];
425
+ });
426
+ }
427
+ // Never their own rows: changing what you made yourself is not a role
428
+ // question, and the row that put them in the team is theirs. And only
429
+ // the team they joined, where the table says which team a row is in:
430
+ // their own team's rows, which they own, were refused by a foreign key
431
+ // on the way out and left the table untested.
432
+ const where = [];
433
+ if (owner) where.push(quote(owner) + " IS DISTINCT FROM '" + attack.USER_B + "'");
434
+ for (const name of Object.keys(pointed)) {
435
+ where.push(quote(name) + " = '" + String(pointed[name]).split("'").join("''") + "'");
436
+ }
437
+ const notTheirs = where.length ? ' WHERE ' + where.join(' AND ') : '';
438
+ const target = quote(schema) + '.' + quote(table.name);
439
+ const column = quote(firstWritable(table));
440
+ const moves = [
441
+ { what: 'change', statement: 'UPDATE ' + target + ' SET ' + column + ' = ' + column + notTheirs, values: [] },
442
+ { what: 'delete', statement: 'DELETE FROM ' + target + notTheirs, values: [] },
443
+ ];
444
+ // Adding a row to the team, said only of a role whose own name says it
445
+ // may only look: a member adding rows is the feature, a viewer adding
446
+ // them is not. Found on a blind test (HelixOps): any member could add
447
+ // alert subscriptions. Only a table that names the team itself. Written
448
+ // under the other person's name: a rule that lets a member add a row
449
+ // only as themselves - a note they wrote - is a member speaking for
450
+ // themselves, and refuses it (OrbitDesk's internal notes, by design).
451
+ if (lookOnly(member.lowest) && toTeam) {
452
+ try {
453
+ const row = await attack.rowFor(client, schema, table, attack.USER_A, 11, pointed, shapeFor.get(table.name));
454
+ moves.unshift({
455
+ what: 'add',
456
+ statement: row.columns.length
457
+ ? 'INSERT INTO ' + target + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' +
458
+ row.values.map((_, i) => '$' + (i + 1)).join(', ') + ')'
459
+ : 'INSERT INTO ' + target + ' DEFAULT VALUES',
460
+ values: row.values,
461
+ });
462
+ } catch (err) {
463
+ // Nothing to point a new row at: there is no row to add.
464
+ }
465
+ }
466
+ const can = [];
467
+ const changed = {};
468
+ let stuck = null;
469
+ for (const move of moves) {
470
+ const before = await tryWrite(client, 'authenticated', attack.USER_B, move.statement, move.values);
471
+ const after = await tryWrite(client, 'authenticated', attack.USER_B, move.statement, move.values, join);
472
+ const was = counted(before);
473
+ const now = counted(after);
474
+ if (was === null || now === null) {
475
+ stuck = now === null ? after.why : before.why;
476
+ } else if (now > was) {
477
+ can.push(move.what);
478
+ changed[move.what] = now - was;
479
+ }
480
+ }
481
+ if (!can.length && stuck) {
482
+ blocked.push({ table: table.name, key: key, why: as(member.lowest) + ': ' + stuck });
483
+ } else {
484
+ completed.push(key);
485
+ if (can.length) {
486
+ findings.push({
487
+ kind: 'role',
488
+ table: table.name,
489
+ who: member.lowest,
490
+ via: member.table.name,
491
+ roleColumn: member.role,
492
+ parent: parent,
493
+ can: can,
494
+ changed: changed,
495
+ columns: (table.columns || []).map((c) => c.name),
496
+ rlsEnabled: table.rlsEnabled,
497
+ });
498
+ }
499
+ }
500
+
501
+ // Reading what a member should not see, said only of a table holding a
502
+ // secret. Every role not in charge, from the lowest up; the first that
503
+ // can read is the one named. A member reading their team's ordinary
504
+ // rows is the feature.
505
+ const secrets = secretColumns(table);
506
+ if (!secrets.length) continue;
507
+ const look = 'SELECT 1 FROM ' + target + notTheirs;
508
+ const before = counted(await tryWrite(client, 'authenticated', attack.USER_B, look, []));
509
+ const tried = [];
510
+ for (const role of member.ladder) {
511
+ const readKey = 'teamread:' + table.name + ':' + role;
512
+ let after;
513
+ try {
514
+ after = await tryWrite(client, 'authenticated', attack.USER_B, look, [], await joinAs(role));
515
+ } catch (err) {
516
+ after = { ok: false, count: 0, why: err.message };
517
+ }
518
+ const now = counted(after);
519
+ if (before === null || now === null) {
520
+ blocked.push({ table: table.name, key: readKey, why: as(role) + ': ' + (now === null ? after.why : 'reading it failed before joining') });
521
+ break;
522
+ }
523
+ completed.push(readKey);
524
+ if (now > before) {
525
+ findings.push({
526
+ kind: 'teamread',
527
+ table: table.name,
528
+ who: role,
529
+ via: member.table.name,
530
+ roleColumn: member.role,
531
+ parent: parent,
532
+ readable: now - before,
533
+ secrets: secrets,
534
+ below: tried.slice(),
535
+ columns: (table.columns || []).map((c) => c.name),
536
+ rlsEnabled: table.rlsEnabled,
537
+ });
538
+ break;
539
+ }
540
+ tried.push(role);
541
+ }
542
+ }
543
+ }
544
+ return { findings: findings, completed: completed, blocked: blocked };
545
+ }
546
+
220
547
  module.exports = {
221
548
  ACTORS: ACTORS,
222
549
  tryWrite: tryWrite,
@@ -224,4 +551,10 @@ module.exports = {
224
551
  firstWritable: firstWritable,
225
552
  writeAsFor: writeAsFor,
226
553
  tamper: tamper,
554
+ lowestRole: lowestRole,
555
+ secretColumns: secretColumns,
556
+ lookOnly: lookOnly,
557
+ roleColumnOf: roleColumnOf,
558
+ memberships: memberships,
559
+ teammate: teammate,
227
560
  };