kryptheon-night 0.1.26 → 0.1.29

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/tamper.js CHANGED
@@ -244,6 +244,24 @@ const LEAST_TRUSTED = [/view/i, /read/i, /guest/i, /observ/i, /audit/i];
244
244
  const IN_CHARGE = /owner|admin|super|root/i;
245
245
  // Owner columns that name who made a row rather than who is in it.
246
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
+ }
247
265
 
248
266
  /** The role a viewer has, out of a team's list of roles, or null. */
249
267
  function lowestRole(labels) {
@@ -267,7 +285,11 @@ function roleColumnOf(table) {
267
285
  : attack.allowedByCheck(table, column.name);
268
286
  if (labels.length < 2) continue;
269
287
  const lowest = lowestRole(labels);
270
- if (lowest) return { column: column.name, lowest: lowest };
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, type: column.type, lowest: lowest, ladder: ladder };
271
293
  }
272
294
  return null;
273
295
  }
@@ -303,13 +325,51 @@ function memberships(tables) {
303
325
  const keys = attack.foreignKeys(table).concat(attack.impliedKeys(table, tables))
304
326
  .filter((key) => key.refTable !== table.name && names.has(key.refTable) && !key.columns.includes(person));
305
327
  if (!keys.length || aloneUnique(table, person)) continue;
306
- found.push({ table: table, person: person, role: role.column, lowest: role.lowest, keys: keys });
328
+ found.push({ table: table, person: person, role: role.column, roleType: role.type, lowest: role.lowest,
329
+ ladder: role.ladder, keys: keys });
307
330
  }
308
331
  // A table named for members first: it is the one a team rule asks.
309
332
  const named = (m) => (/member/i.test(m.table.name) ? 0 : 1);
310
333
  return found.map((m, i) => [m, i]).sort((a, b) => named(a[0]) - named(b[0]) || a[1] - b[1]).map((pair) => pair[0]);
311
334
  }
312
335
 
336
+ // What each kind of write is called in a rule.
337
+ const COMMAND = { add: 'INSERT', change: 'UPDATE', delete: 'DELETE' };
338
+
339
+ /**
340
+ * Whether the app's own rules name this role among those allowed to make
341
+ * this kind of write - the way a repository's role rule does it: the role in
342
+ * a list in a permissive rule for that command, has_role(team,
343
+ * ARRAY['owner', 'maintainer', 'developer']), or in a list inside a helper
344
+ * that rule calls (role IN ('owner', 'developer')). A rule written for one
345
+ * role on its own - role = 'analyst', the shape production added in
346
+ * LaunchRail - is not a list of who may write, and stays something to check.
347
+ */
348
+ function allowedByRules(rules, table, what, role, type) {
349
+ const escape = (x) => String(x).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
350
+ const label = "'" + escape(String(role).split("'").join("''")) + "'";
351
+ const typeName = escape(String(type || 'text').split('.').pop().split('"').join(''));
352
+ const inRuleList = new RegExp('ARRAY\\[[^\\]]*' + label + '::("?[A-Za-z0-9_$]+"?\\.)?"?' + typeName + '"?', 'i');
353
+ // The same list written as an array literal - has_role(team,
354
+ // '{owner,admin,member}') - which Postgres keeps as '{...}'::role[].
355
+ const bare = escape(String(role));
356
+ const inRuleLiteral = new RegExp("'\\{([^}']*,)?\"?" + bare + "\"?(,[^}']*)?\\}'::(\"?[A-Za-z0-9_$]+\"?\\.)?\"?" +
357
+ typeName + '"?\\[\\]', 'i');
358
+ const inHelperList = new RegExp('(\\bIN\\s*\\(|ARRAY\\[)[^)\\]]*' + label, 'i');
359
+ const calls = (text, name) => new RegExp('(^|[^A-Za-z0-9_$])"?' + escape(name) + '"?\\s*\\(').test(text);
360
+ const functions = (rules && rules.functions) || [];
361
+ for (const policy of (rules && rules.policies) || []) {
362
+ if (policy.table_name !== table) continue;
363
+ if (String(policy.permissive || 'PERMISSIVE').toUpperCase() !== 'PERMISSIVE') continue;
364
+ const cmd = String(policy.cmd || 'ALL').toUpperCase();
365
+ if (cmd !== 'ALL' && cmd !== COMMAND[what]) continue;
366
+ const text = String(policy.qual || '') + ' ' + String(policy.with_check || '');
367
+ if (inRuleList.test(text) || inRuleLiteral.test(text)) return true;
368
+ if (functions.some((fn) => calls(text, fn.name) && inHelperList.test(String(fn.src || '')))) return true;
369
+ }
370
+ return false;
371
+ }
372
+
313
373
  /**
314
374
  * For every team table: can a viewer change it?
315
375
  *
@@ -319,12 +379,17 @@ function memberships(tables) {
319
379
  * and a person changing their own rows is the feature. Both rolled back, the
320
380
  * joining with them.
321
381
  */
322
- async function teammate(client, schema, tables, seeded) {
382
+ async function teammate(client, schema, tables, seeded, rules) {
323
383
  const findings = [];
324
384
  const completed = [];
325
385
  const blocked = [];
326
386
  const shapeFor = new Map((seeded || []).map((entry) => [entry.table, entry.attempt || 0]));
327
387
  const done = new Set();
388
+ // What a write or a read achieved, or null when it decided nothing.
389
+ const counted = (result) => {
390
+ const outcome = whatHappened(result);
391
+ return outcome === 'untested' ? null : outcome === 'got through' ? result.count : 0;
392
+ };
328
393
 
329
394
  for (const member of memberships(tables)) {
330
395
  if (!shapeFor.has(member.table.name)) continue;
@@ -339,22 +404,32 @@ async function teammate(client, schema, tables, seeded) {
339
404
  if (!theirs) continue;
340
405
 
341
406
  // Their team, as the first person's own membership names it.
342
- const overrides = {};
343
- overrides[member.role] = member.lowest;
407
+ const team = {};
344
408
  for (const key of member.keys) {
345
- key.columns.forEach((name) => { if (name !== member.person) overrides[name] = theirs[name]; });
409
+ key.columns.forEach((name) => { if (name !== member.person) team[name] = theirs[name]; });
346
410
  }
347
- const parent = member.keys[0].refTable;
348
- const as = 'as a "' + member.lowest + '" in ' + member.table.name;
349
- let join = null;
350
- let refused = null;
351
- try {
411
+ const parentKey = member.keys[0];
412
+ const parent = parentKey.refTable;
413
+ // Putting the other person in that team, as one role. Built once a role.
414
+ const joins = new Map();
415
+ const joinAs = async (role) => {
416
+ if (joins.has(role)) return joins.get(role);
417
+ const overrides = Object.assign({}, team);
418
+ overrides[member.role] = role;
352
419
  const row = await attack.rowFor(client, schema, member.table, attack.USER_B, 9, overrides, shapeFor.get(member.table.name));
353
- join = {
420
+ const join = {
354
421
  statement: 'INSERT INTO ' + at + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' +
355
422
  row.values.map((_, i) => '$' + (i + 1)).join(', ') + ')',
356
423
  values: row.values,
357
424
  };
425
+ joins.set(role, join);
426
+ return join;
427
+ };
428
+ const as = (role) => 'as a "' + role + '" in ' + member.table.name;
429
+ let join = null;
430
+ let refused = null;
431
+ try {
432
+ join = await joinAs(member.lowest);
358
433
  // Whether they can be put in the team at all, tried once on its own.
359
434
  await client.query('BEGIN');
360
435
  try {
@@ -369,61 +444,174 @@ async function teammate(client, schema, tables, seeded) {
369
444
  // Said once, on the table that would not take them: every team table
370
445
  // went untested for the same reason.
371
446
  blocked.push({ table: member.table.name, key: 'role:' + member.table.name + ':' + member.lowest,
372
- why: as + ': I could not add one: ' + refused });
447
+ why: as(member.lowest) + ': I could not add one: ' + refused });
373
448
  continue;
374
449
  }
375
450
 
376
451
  for (const table of tables) {
377
452
  if (!shapeFor.has(table.name) || done.has(table.name)) continue;
378
453
  done.add(table.name);
379
- const key = 'role:' + table.name + ':' + member.lowest;
380
454
  const owner = attack.ownerColumn(table);
455
+ // A table that names the team itself, and the team it is to name.
456
+ const toTeam = attack.foreignKeys(table).concat(attack.impliedKeys(table, tables))
457
+ .find((k) => k.refTable === parent && k.columns.length === parentKey.refColumns.length);
458
+ const pointed = {};
459
+ if (toTeam) {
460
+ toTeam.columns.forEach((name, i) => {
461
+ pointed[name] = team[parentKey.columns[parentKey.refColumns.indexOf(toTeam.refColumns[i])]];
462
+ });
463
+ }
381
464
  // Never their own rows: changing what you made yourself is not a role
382
- // question, and the row that put them in the team is theirs.
383
- const notTheirs = owner ? ' WHERE ' + quote(owner) + " IS DISTINCT FROM '" + attack.USER_B + "'" : '';
465
+ // question, and the row that put them in the team is theirs. And only
466
+ // the team they joined, where the table says which team a row is in:
467
+ // their own team's rows, which they own, were refused by a foreign key
468
+ // on the way out and left the table untested.
469
+ const where = [];
470
+ if (owner) where.push(quote(owner) + " IS DISTINCT FROM '" + attack.USER_B + "'");
471
+ for (const name of Object.keys(pointed)) {
472
+ where.push(quote(name) + " = '" + String(pointed[name]).split("'").join("''") + "'");
473
+ }
474
+ const notTheirs = where.length ? ' WHERE ' + where.join(' AND ') : '';
384
475
  const target = quote(schema) + '.' + quote(table.name);
385
476
  const column = quote(firstWritable(table));
386
477
  const moves = [
387
- { what: 'change', statement: 'UPDATE ' + target + ' SET ' + column + ' = ' + column + notTheirs },
388
- { what: 'delete', statement: 'DELETE FROM ' + target + notTheirs },
478
+ { what: 'change', statement: 'UPDATE ' + target + ' SET ' + column + ' = ' + column + notTheirs, values: [] },
479
+ { what: 'delete', statement: 'DELETE FROM ' + target + notTheirs, values: [] },
389
480
  ];
390
- const can = [];
391
- const changed = {};
392
- let stuck = null;
393
- for (const move of moves) {
394
- const before = await tryWrite(client, 'authenticated', attack.USER_B, move.statement, []);
395
- const after = await tryWrite(client, 'authenticated', attack.USER_B, move.statement, [], join);
396
- const counted = (result) => {
397
- const outcome = whatHappened(result);
398
- return outcome === 'untested' ? null : outcome === 'got through' ? result.count : 0;
399
- };
400
- const was = counted(before);
401
- const now = counted(after);
402
- if (was === null || now === null) {
403
- stuck = now === null ? after.why : before.why;
404
- } else if (now > was) {
405
- can.push(move.what);
406
- changed[move.what] = now - was;
481
+ // Adding a row to the team, said only of a role whose own name says it
482
+ // may only look: a member adding rows is the feature, a viewer adding
483
+ // them is not. Found on a blind test (HelixOps): any member could add
484
+ // alert subscriptions. Only a table that names the team itself. Written
485
+ // under the other person's name: a rule that lets a member add a row
486
+ // only as themselves - a note they wrote - is a member speaking for
487
+ // themselves, and refuses it (OrbitDesk's internal notes, by design).
488
+ if (toTeam && member.ladder.some(lookOnly)) {
489
+ try {
490
+ const row = await attack.rowFor(client, schema, table, attack.USER_A, 11, pointed, shapeFor.get(table.name));
491
+ moves.unshift({
492
+ what: 'add',
493
+ lookOnly: true,
494
+ statement: row.columns.length
495
+ ? 'INSERT INTO ' + target + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' +
496
+ row.values.map((_, i) => '$' + (i + 1)).join(', ') + ')'
497
+ : 'INSERT INTO ' + target + ' DEFAULT VALUES',
498
+ values: row.values,
499
+ });
500
+ } catch (err) {
501
+ // Nothing to point a new row at: there is no row to add.
407
502
  }
408
503
  }
409
- if (!can.length && stuck) {
410
- blocked.push({ table: table.name, key: key, why: as + ': ' + stuck });
411
- continue;
504
+
505
+ // Every role not in charge, from the lowest up, the way reads are tried:
506
+ // found on a blind test (LaunchRail), where production gave the
507
+ // "analyst" role its own UPDATE rule on feature flags and only the
508
+ // "viewer" below it was ever tried. The first role that can write where
509
+ // the app's own rules do not list it is the one named; a role a rule
510
+ // lists among those allowed to write is the design, and the roles above
511
+ // it are still tried. What each write did before joining is asked once.
512
+ const beforeJoining = new Map();
513
+ for (const move of moves) {
514
+ beforeJoining.set(move.what, await tryWrite(client, 'authenticated', attack.USER_B, move.statement, move.values));
412
515
  }
413
- completed.push(key);
414
- if (can.length) {
415
- findings.push({
416
- kind: 'role',
417
- table: table.name,
418
- who: member.lowest,
419
- via: member.table.name,
420
- roleColumn: member.role,
421
- parent: parent,
422
- can: can,
423
- changed: changed,
424
- columns: (table.columns || []).map((c) => c.name),
425
- rlsEnabled: table.rlsEnabled,
426
- });
516
+ const below = [];
517
+ for (const role of member.ladder) {
518
+ const key = 'role:' + table.name + ':' + role;
519
+ let joined;
520
+ try {
521
+ joined = await joinAs(role);
522
+ } catch (err) {
523
+ blocked.push({ table: table.name, key: key, why: as(role) + ': I could not add one: ' + err.message });
524
+ break;
525
+ }
526
+ const can = [];
527
+ const changed = {};
528
+ let stuck = null;
529
+ // Whether this role could write where the rules list it: then it is
530
+ // not one that "could not", whatever is said of the roles above it.
531
+ let listed = false;
532
+ for (const move of moves) {
533
+ if (move.lookOnly && !lookOnly(role)) continue;
534
+ // A write the rules list this role for is not asked: whatever it
535
+ // did would not be reported, and a key refusing it on the way out
536
+ // left the table untested (HelixOps' assets, as a dispatcher).
537
+ if (allowedByRules(rules, table.name, move.what, role, member.roleType)) {
538
+ listed = true;
539
+ continue;
540
+ }
541
+ const after = await tryWrite(client, 'authenticated', attack.USER_B, move.statement, move.values, joined);
542
+ const was = counted(beforeJoining.get(move.what));
543
+ const now = counted(after);
544
+ if (was === null || now === null) {
545
+ stuck = now === null ? after.why : beforeJoining.get(move.what).why;
546
+ } else if (now > was) {
547
+ can.push(move.what);
548
+ changed[move.what] = now - was;
549
+ }
550
+ }
551
+ if (!can.length && stuck) {
552
+ blocked.push({ table: table.name, key: key, why: as(role) + ': ' + stuck });
553
+ break;
554
+ }
555
+ completed.push(key);
556
+ if (can.length) {
557
+ findings.push({
558
+ kind: 'role',
559
+ table: table.name,
560
+ who: role,
561
+ via: member.table.name,
562
+ roleColumn: member.role,
563
+ parent: parent,
564
+ can: can,
565
+ changed: changed,
566
+ below: below.slice(),
567
+ columns: (table.columns || []).map((c) => c.name),
568
+ rlsEnabled: table.rlsEnabled,
569
+ });
570
+ break;
571
+ }
572
+ if (!listed) below.push(role);
573
+ }
574
+
575
+ // Reading what a member should not see, said only of a table holding a
576
+ // secret. Every role not in charge, from the lowest up; the first that
577
+ // can read is the one named. A member reading their team's ordinary
578
+ // rows is the feature.
579
+ const secrets = secretColumns(table);
580
+ if (!secrets.length) continue;
581
+ const look = 'SELECT 1 FROM ' + target + notTheirs;
582
+ const before = counted(await tryWrite(client, 'authenticated', attack.USER_B, look, []));
583
+ const tried = [];
584
+ for (const role of member.ladder) {
585
+ const readKey = 'teamread:' + table.name + ':' + role;
586
+ let after;
587
+ try {
588
+ after = await tryWrite(client, 'authenticated', attack.USER_B, look, [], await joinAs(role));
589
+ } catch (err) {
590
+ after = { ok: false, count: 0, why: err.message };
591
+ }
592
+ const now = counted(after);
593
+ if (before === null || now === null) {
594
+ blocked.push({ table: table.name, key: readKey, why: as(role) + ': ' + (now === null ? after.why : 'reading it failed before joining') });
595
+ break;
596
+ }
597
+ completed.push(readKey);
598
+ if (now > before) {
599
+ findings.push({
600
+ kind: 'teamread',
601
+ table: table.name,
602
+ who: role,
603
+ via: member.table.name,
604
+ roleColumn: member.role,
605
+ parent: parent,
606
+ readable: now - before,
607
+ secrets: secrets,
608
+ below: tried.slice(),
609
+ columns: (table.columns || []).map((c) => c.name),
610
+ rlsEnabled: table.rlsEnabled,
611
+ });
612
+ break;
613
+ }
614
+ tried.push(role);
427
615
  }
428
616
  }
429
617
  }
@@ -438,6 +626,9 @@ module.exports = {
438
626
  writeAsFor: writeAsFor,
439
627
  tamper: tamper,
440
628
  lowestRole: lowestRole,
629
+ allowedByRules: allowedByRules,
630
+ secretColumns: secretColumns,
631
+ lookOnly: lookOnly,
441
632
  roleColumnOf: roleColumnOf,
442
633
  memberships: memberships,
443
634
  teammate: teammate,