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/README.md +1 -1
- package/attack.js +63 -2
- package/bin/kryptheon-night.js +3 -1
- package/collision.js +15 -0
- package/engine.sql +365 -63
- package/finding.js +84 -18
- package/package.json +1 -1
- package/recheck.js +135 -14
- package/scan.js +23 -5
- package/sqlengine.js +4 -4
- package/tamper.js +243 -52
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
|
-
|
|
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,
|
|
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
|
|
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)
|
|
409
|
+
key.columns.forEach((name) => { if (name !== member.person) team[name] = theirs[name]; });
|
|
346
410
|
}
|
|
347
|
-
const
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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,
|