kryptheon-night 0.1.26 → 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/README.md +1 -1
- package/attack.js +63 -2
- package/bin/kryptheon-night.js +3 -1
- package/engine.sql +266 -50
- package/finding.js +59 -8
- package/package.json +1 -1
- package/recheck.js +135 -14
- package/scan.js +18 -3
- package/tamper.js +154 -38
package/README.md
CHANGED
|
@@ -74,7 +74,7 @@ whether they are meant:
|
|
|
74
74
|
|
|
75
75
|
| check | the question |
|
|
76
76
|
|---|---|
|
|
77
|
-
| **Lowest role** | can a viewer in a team change what is in it? (tried on the copy, undone) |
|
|
77
|
+
| **Lowest role** | can a viewer in a team change or add to what is in it, and which roles can read its tokens, keys and payloads? (tried on the copy, undone) |
|
|
78
78
|
| **Storage** | is a bucket public although a rule says who may read it? (settings only, never a file) |
|
|
79
79
|
| **Open functions** | can a visitor with no account call a function that skips the rules? |
|
|
80
80
|
|
package/attack.js
CHANGED
|
@@ -204,6 +204,48 @@ function allowedByCheck(table, columnName) {
|
|
|
204
204
|
return found;
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Sets of columns where exactly one may be filled in.
|
|
209
|
+
*
|
|
210
|
+
* Found on a blind test (HelixOps): documents belonged to a work order or to
|
|
211
|
+
* an inspection, never both, written
|
|
212
|
+
* CHECK ((work_order_id IS NOT NULL)::int + (inspection_id IS NOT NULL)::int = 1)
|
|
213
|
+
* Both parents were there, so the seeder pointed the row at both, the CHECK
|
|
214
|
+
* refused every attempt, and the table was never tested at all.
|
|
215
|
+
*
|
|
216
|
+
* Two spellings are read - that sum, and num_nonnulls(a, b, ...) = 1 - and
|
|
217
|
+
* only when the constraint says nothing else. A CHECK with anything more in it
|
|
218
|
+
* is left alone rather than half understood.
|
|
219
|
+
*/
|
|
220
|
+
function exactlyOne(table) {
|
|
221
|
+
const groups = [];
|
|
222
|
+
const name = '("(?:[^"]|"")+"|[A-Za-z_][A-Za-z0-9_$]*)';
|
|
223
|
+
const unquote = (text) => (text.charAt(0) === '"' ? text.slice(1, -1).split('""').join('"') : text);
|
|
224
|
+
for (const constraint of table.constraints || []) {
|
|
225
|
+
if (constraint.kind !== 'c') continue;
|
|
226
|
+
const definition = String(constraint.definition || '');
|
|
227
|
+
let columns = [];
|
|
228
|
+
let rest = definition;
|
|
229
|
+
const summed = new RegExp('\\(\\(' + name + ' IS NOT NULL\\)\\)::integer', 'gi');
|
|
230
|
+
let match;
|
|
231
|
+
while ((match = summed.exec(definition)) !== null) columns.push(unquote(match[1]));
|
|
232
|
+
rest = rest.replace(summed, '').replace(/\+/g, '');
|
|
233
|
+
if (!columns.length) {
|
|
234
|
+
const counted = /num_nonnulls\(([^()]*)\)/i.exec(definition);
|
|
235
|
+
if (counted) {
|
|
236
|
+
const listed = new RegExp(name, 'g');
|
|
237
|
+
let one;
|
|
238
|
+
while ((one = listed.exec(counted[1])) !== null) columns.push(unquote(one[1]));
|
|
239
|
+
rest = rest.replace(counted[0], '');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Nothing may be left but the CHECK itself, brackets and "= 1".
|
|
243
|
+
if (columns.length < 2 || rest.replace(/[()\s]/g, '').toUpperCase() !== 'CHECK=1') continue;
|
|
244
|
+
groups.push(columns);
|
|
245
|
+
}
|
|
246
|
+
return groups;
|
|
247
|
+
}
|
|
248
|
+
|
|
207
249
|
/**
|
|
208
250
|
* The foreign keys on a table, read out of Postgres's own wording.
|
|
209
251
|
*
|
|
@@ -395,12 +437,28 @@ async function rowFor(client, schema, table, person, distinct, overrides, attemp
|
|
|
395
437
|
const partOfKey = new Set();
|
|
396
438
|
for (const key of foreignKeys(table)) key.columns.forEach((name) => partOfKey.add(name));
|
|
397
439
|
|
|
440
|
+
// Where exactly one of a set may be filled in: the one already chosen, or
|
|
441
|
+
// else the first that can be - a key needs a parent row to point at. The
|
|
442
|
+
// rest are written as nothing, whatever their default.
|
|
443
|
+
const chosen = (name) => Object.prototype.hasOwnProperty.call(forced, name);
|
|
444
|
+
const onlyOne = new Map();
|
|
445
|
+
for (const group of exactlyOne(table)) {
|
|
446
|
+
const kept = group.find(chosen) || group.find((name) => !partOfKey.has(name) || borrowed.has(name));
|
|
447
|
+
for (const name of group) onlyOne.set(name, name === kept);
|
|
448
|
+
}
|
|
449
|
+
|
|
398
450
|
for (const column of table.columns) {
|
|
399
451
|
if (Object.prototype.hasOwnProperty.call(forced, column.name)) {
|
|
400
452
|
columns.push(column.name);
|
|
401
453
|
values.push(forced[column.name]);
|
|
402
454
|
continue;
|
|
403
455
|
}
|
|
456
|
+
if (onlyOne.get(column.name) === false) {
|
|
457
|
+
columns.push(column.name);
|
|
458
|
+
values.push(null);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
const mustFill = onlyOne.get(column.name) === true;
|
|
404
462
|
if (column.name === owner) {
|
|
405
463
|
columns.push(column.name);
|
|
406
464
|
values.push(person);
|
|
@@ -436,9 +494,11 @@ async function rowFor(client, schema, table, person, distinct, overrides, attemp
|
|
|
436
494
|
values.push(borrowed.get(column.name));
|
|
437
495
|
continue;
|
|
438
496
|
}
|
|
439
|
-
// Anything with a default can supply its own value.
|
|
497
|
+
// Anything with a default can supply its own value. A column that may be
|
|
498
|
+
// empty is left empty - unless it is the one of a set that has to be
|
|
499
|
+
// filled in.
|
|
440
500
|
if (column.default_expr) continue;
|
|
441
|
-
if (!column.not_null) continue;
|
|
501
|
+
if (!column.not_null && !mustFill) continue;
|
|
442
502
|
columns.push(column.name);
|
|
443
503
|
// A CHECK that lists what it will accept beats anything invented here.
|
|
444
504
|
const allowed = allowedByCheck(table, column.name);
|
|
@@ -766,6 +826,7 @@ module.exports = {
|
|
|
766
826
|
dependencyOrder: dependencyOrder,
|
|
767
827
|
valueFor: valueFor,
|
|
768
828
|
allowedByCheck: allowedByCheck,
|
|
829
|
+
exactlyOne: exactlyOne,
|
|
769
830
|
rowFor: rowFor,
|
|
770
831
|
existingRow: existingRow,
|
|
771
832
|
insertRow: insertRow,
|
package/bin/kryptheon-night.js
CHANGED
|
@@ -648,7 +648,9 @@ async function main() {
|
|
|
648
648
|
if (result.findings.length) scanner.report(result);
|
|
649
649
|
else scanner.notTestedLines(result).forEach(line);
|
|
650
650
|
scanner.saveRun(file, result);
|
|
651
|
-
|
|
651
|
+
// Two different databases: the verdict is a comparison, so the exit
|
|
652
|
+
// code is this run's own answer, the same as a first run.
|
|
653
|
+
process.exitCode = result.stopped ? 2 : verdict.elsewhere ? scanner.exitCodeFor(result) : verdict.allClear ? 0 : 1;
|
|
652
654
|
}
|
|
653
655
|
|
|
654
656
|
// And then the only other thing worth doing, offered rather than
|
package/engine.sql
CHANGED
|
@@ -1625,6 +1625,54 @@ END $$;
|
|
|
1625
1625
|
* and `implied`, the keys the app never declared, so a `workspace_id` with no
|
|
1626
1626
|
* foreign key still names a workspace that is there.
|
|
1627
1627
|
*/
|
|
1628
|
+
/*
|
|
1629
|
+
* Sets of columns where exactly one may be filled in. Mirrors exactlyOne in
|
|
1630
|
+
* attack.js: (a IS NOT NULL)::int + (b IS NOT NULL)::int = 1, and
|
|
1631
|
+
* num_nonnulls(a, b) = 1, read only when the constraint says nothing else.
|
|
1632
|
+
* Found on a blind test (HelixOps), where documents pointed at a work order
|
|
1633
|
+
* or an inspection, the seeder pointed it at both, and it was never tested.
|
|
1634
|
+
*/
|
|
1635
|
+
CREATE OR REPLACE FUNCTION __KN__.exactly_one(tab jsonb)
|
|
1636
|
+
RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
|
|
1637
|
+
DECLARE
|
|
1638
|
+
name_ constant text := '("(?:[^"]|"")+"|[A-Za-z_][A-Za-z0-9_$]*)';
|
|
1639
|
+
summed text := '\(\(' || name_ || ' IS NOT NULL\)\)::integer';
|
|
1640
|
+
groups jsonb := '[]'::jsonb;
|
|
1641
|
+
con jsonb;
|
|
1642
|
+
definition text;
|
|
1643
|
+
rest text;
|
|
1644
|
+
counted text[];
|
|
1645
|
+
columns jsonb;
|
|
1646
|
+
found text;
|
|
1647
|
+
BEGIN
|
|
1648
|
+
FOR con IN SELECT * FROM jsonb_array_elements(coalesce(tab->'constraints', '[]'::jsonb)) LOOP
|
|
1649
|
+
CONTINUE WHEN con->>'kind' <> 'c';
|
|
1650
|
+
definition := coalesce(con->>'definition', '');
|
|
1651
|
+
columns := '[]'::jsonb;
|
|
1652
|
+
FOR found IN SELECT (m)[1] FROM regexp_matches(definition, summed, 'gi') m LOOP
|
|
1653
|
+
columns := columns || to_jsonb(found);
|
|
1654
|
+
END LOOP;
|
|
1655
|
+
rest := replace(regexp_replace(definition, summed, '', 'gi'), '+', '');
|
|
1656
|
+
IF jsonb_array_length(columns) = 0 THEN
|
|
1657
|
+
counted := regexp_match(definition, 'num_nonnulls\(([^()]*)\)', 'i');
|
|
1658
|
+
IF counted IS NOT NULL THEN
|
|
1659
|
+
FOR found IN SELECT (m)[1] FROM regexp_matches(counted[1], name_, 'g') m LOOP
|
|
1660
|
+
columns := columns || to_jsonb(found);
|
|
1661
|
+
END LOOP;
|
|
1662
|
+
rest := regexp_replace(rest, 'num_nonnulls\([^()]*\)', '', 'i');
|
|
1663
|
+
END IF;
|
|
1664
|
+
END IF;
|
|
1665
|
+
-- Nothing may be left but the CHECK itself, brackets and "= 1".
|
|
1666
|
+
CONTINUE WHEN jsonb_array_length(columns) < 2
|
|
1667
|
+
OR upper(regexp_replace(rest, '[()\s]', '', 'g')) <> 'CHECK=1';
|
|
1668
|
+
groups := groups || jsonb_build_array((
|
|
1669
|
+
SELECT jsonb_agg(CASE WHEN left(c, 1) = '"' THEN replace(substr(c, 2, length(c) - 2), '""', '"') ELSE c END
|
|
1670
|
+
ORDER BY at)
|
|
1671
|
+
FROM jsonb_array_elements_text(columns) WITH ORDINALITY AS e(c, at)));
|
|
1672
|
+
END LOOP;
|
|
1673
|
+
RETURN groups;
|
|
1674
|
+
END $$;
|
|
1675
|
+
|
|
1628
1676
|
CREATE OR REPLACE FUNCTION __KN__.row_for(
|
|
1629
1677
|
source text, tab jsonb, person text, distinct_ text, overrides jsonb, attempt integer,
|
|
1630
1678
|
seeding jsonb DEFAULT NULL)
|
|
@@ -1636,6 +1684,10 @@ DECLARE
|
|
|
1636
1684
|
values_ jsonb := '[]'::jsonb;
|
|
1637
1685
|
borrowed jsonb := '{}'::jsonb;
|
|
1638
1686
|
part_of_key jsonb := '[]'::jsonb;
|
|
1687
|
+
only_one jsonb := '{}'::jsonb;
|
|
1688
|
+
group_ jsonb;
|
|
1689
|
+
kept text;
|
|
1690
|
+
must_fill boolean;
|
|
1639
1691
|
key jsonb;
|
|
1640
1692
|
row_ jsonb;
|
|
1641
1693
|
col jsonb;
|
|
@@ -1664,6 +1716,22 @@ BEGIN
|
|
|
1664
1716
|
part_of_key := part_of_key || (key->'columns');
|
|
1665
1717
|
END LOOP;
|
|
1666
1718
|
|
|
1719
|
+
-- Where exactly one of a set may be filled in: the one already chosen, or
|
|
1720
|
+
-- else the first that can be - a key needs a parent row to point at. The
|
|
1721
|
+
-- rest are written as nothing, whatever their default.
|
|
1722
|
+
FOR group_ IN SELECT * FROM jsonb_array_elements(__KN__.exactly_one(tab)) LOOP
|
|
1723
|
+
SELECT c INTO kept FROM jsonb_array_elements_text(group_) WITH ORDINALITY AS e(c, pos)
|
|
1724
|
+
WHERE forced ? c ORDER BY pos LIMIT 1;
|
|
1725
|
+
IF kept IS NULL THEN
|
|
1726
|
+
SELECT c INTO kept FROM jsonb_array_elements_text(group_) WITH ORDINALITY AS e(c, pos)
|
|
1727
|
+
WHERE NOT (part_of_key ? c) OR borrowed ? c ORDER BY pos LIMIT 1;
|
|
1728
|
+
END IF;
|
|
1729
|
+
FOR name IN SELECT jsonb_array_elements_text(group_) LOOP
|
|
1730
|
+
only_one := jsonb_set(only_one, ARRAY[name], to_jsonb(name = kept));
|
|
1731
|
+
END LOOP;
|
|
1732
|
+
kept := NULL;
|
|
1733
|
+
END LOOP;
|
|
1734
|
+
|
|
1667
1735
|
FOR col IN SELECT * FROM jsonb_array_elements(tab->'columns') LOOP
|
|
1668
1736
|
name := col->>'name';
|
|
1669
1737
|
|
|
@@ -1673,6 +1741,13 @@ BEGIN
|
|
|
1673
1741
|
CONTINUE;
|
|
1674
1742
|
END IF;
|
|
1675
1743
|
|
|
1744
|
+
IF only_one->name = 'false'::jsonb THEN
|
|
1745
|
+
columns := columns || to_jsonb(name);
|
|
1746
|
+
values_ := values_ || jsonb_build_array('null'::jsonb);
|
|
1747
|
+
CONTINUE;
|
|
1748
|
+
END IF;
|
|
1749
|
+
must_fill := coalesce(only_one->name = 'true'::jsonb, false);
|
|
1750
|
+
|
|
1676
1751
|
IF name = owner THEN
|
|
1677
1752
|
columns := columns || to_jsonb(name);
|
|
1678
1753
|
values_ := values_ || jsonb_build_array(to_jsonb(person));
|
|
@@ -1712,9 +1787,11 @@ BEGIN
|
|
|
1712
1787
|
values_ := values_ || jsonb_build_array(borrowed->name);
|
|
1713
1788
|
CONTINUE;
|
|
1714
1789
|
END IF;
|
|
1715
|
-
-- Anything with a default can supply its own value.
|
|
1790
|
+
-- Anything with a default can supply its own value. A column that may be
|
|
1791
|
+
-- empty is left empty - unless it is the one of a set that has to be
|
|
1792
|
+
-- filled in.
|
|
1716
1793
|
CONTINUE WHEN col->>'default_expr' IS NOT NULL;
|
|
1717
|
-
CONTINUE WHEN NOT (col->>'not_null')::boolean;
|
|
1794
|
+
CONTINUE WHEN NOT (col->>'not_null')::boolean AND NOT must_fill;
|
|
1718
1795
|
|
|
1719
1796
|
columns := columns || to_jsonb(name);
|
|
1720
1797
|
-- A CHECK that lists what it will accept beats anything invented here.
|
|
@@ -2414,12 +2491,36 @@ BEGIN
|
|
|
2414
2491
|
CONTINUE WHEN labels IS NULL OR jsonb_array_length(labels) < 2;
|
|
2415
2492
|
lowest := __KN__.lowest_role(labels);
|
|
2416
2493
|
IF lowest IS NOT NULL THEN
|
|
2417
|
-
|
|
2494
|
+
-- Every role not plainly in charge, the lowest first and then upwards:
|
|
2495
|
+
-- the order reads are tried in.
|
|
2496
|
+
RETURN jsonb_build_object('column', col->>'name', 'lowest', lowest, 'ladder',
|
|
2497
|
+
jsonb_build_array(lowest) || coalesce((
|
|
2498
|
+
SELECT jsonb_agg(l ORDER BY pos DESC)
|
|
2499
|
+
FROM jsonb_array_elements_text(labels) WITH ORDINALITY AS e(l, pos)
|
|
2500
|
+
WHERE l <> lowest AND l !~* 'owner|admin|super|root'), '[]'::jsonb));
|
|
2418
2501
|
END IF;
|
|
2419
2502
|
END LOOP;
|
|
2420
2503
|
RETURN NULL;
|
|
2421
2504
|
END $$;
|
|
2422
2505
|
|
|
2506
|
+
/*
|
|
2507
|
+
* The columns of a table that look like they hold a secret: a token, a secret
|
|
2508
|
+
* or a key, a password, or the payload a webhook carried. Mirrors
|
|
2509
|
+
* secretColumns in tamper.js. Found on a blind test (HelixOps).
|
|
2510
|
+
*/
|
|
2511
|
+
CREATE OR REPLACE FUNCTION __KN__.secret_columns(tab jsonb)
|
|
2512
|
+
RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
|
|
2513
|
+
SELECT coalesce(jsonb_agg(c->>'name' ORDER BY pos), '[]'::jsonb)
|
|
2514
|
+
FROM jsonb_array_elements(coalesce(tab->'columns', '[]'::jsonb)) WITH ORDINALITY AS e(c, pos)
|
|
2515
|
+
WHERE (c->>'name') ~* '(^|_)(token|secret|password|passwd|pwd|payload|credentials?|(api|private|access|client|signing|service|encryption|secret|license)_?key|(request|response)_body)(_|$)';
|
|
2516
|
+
$$;
|
|
2517
|
+
|
|
2518
|
+
/* Whether a role's own name says it may only look. */
|
|
2519
|
+
CREATE OR REPLACE FUNCTION __KN__.look_only(role_ text)
|
|
2520
|
+
RETURNS boolean LANGUAGE sql IMMUTABLE AS $$
|
|
2521
|
+
SELECT role_ ~* 'view' OR role_ ~* 'read' OR role_ ~* 'guest' OR role_ ~* 'observ' OR role_ ~* 'audit';
|
|
2522
|
+
$$;
|
|
2523
|
+
|
|
2423
2524
|
/* Whether a column is a key on its own: one row per person, at most. */
|
|
2424
2525
|
CREATE OR REPLACE FUNCTION __KN__.alone_unique(tab jsonb, column_name text)
|
|
2425
2526
|
RETURNS boolean LANGUAGE sql IMMUTABLE AS $$
|
|
@@ -2461,7 +2562,8 @@ BEGIN
|
|
|
2461
2562
|
AND NOT (k->'columns' ? person);
|
|
2462
2563
|
CONTINUE WHEN jsonb_array_length(keys) = 0 OR __KN__.alone_unique(tab, person);
|
|
2463
2564
|
found := found || jsonb_build_array(jsonb_build_object(
|
|
2464
|
-
'table', tab, 'person', person, 'role', role_->>'column', 'lowest', role_->>'lowest',
|
|
2565
|
+
'table', tab, 'person', person, 'role', role_->>'column', 'lowest', role_->>'lowest',
|
|
2566
|
+
'ladder', role_->'ladder', 'keys', keys));
|
|
2465
2567
|
END LOOP;
|
|
2466
2568
|
-- A table named for members first: it is the one a team rule asks.
|
|
2467
2569
|
SELECT coalesce(jsonb_agg(m ORDER BY (m->'table'->>'name') !~* 'member', at), '[]'::jsonb) INTO found
|
|
@@ -2533,20 +2635,27 @@ DECLARE
|
|
|
2533
2635
|
shape integer;
|
|
2534
2636
|
at text;
|
|
2535
2637
|
theirs jsonb;
|
|
2638
|
+
team jsonb;
|
|
2536
2639
|
overrides jsonb;
|
|
2640
|
+
joins jsonb;
|
|
2537
2641
|
key jsonb;
|
|
2538
2642
|
name text;
|
|
2539
2643
|
parent text;
|
|
2540
|
-
|
|
2644
|
+
parent_key jsonb;
|
|
2645
|
+
to_team jsonb;
|
|
2646
|
+
pointed jsonb;
|
|
2541
2647
|
row_ jsonb;
|
|
2542
2648
|
join_ text;
|
|
2543
2649
|
refused text;
|
|
2544
2650
|
owner text;
|
|
2651
|
+
where_ text[];
|
|
2545
2652
|
not_theirs text;
|
|
2546
2653
|
target text;
|
|
2547
2654
|
column_ text;
|
|
2548
2655
|
finding_key text;
|
|
2549
|
-
|
|
2656
|
+
read_key text;
|
|
2657
|
+
moves jsonb;
|
|
2658
|
+
move jsonb;
|
|
2550
2659
|
was bigint;
|
|
2551
2660
|
now_ bigint;
|
|
2552
2661
|
before_ jsonb;
|
|
@@ -2554,6 +2663,11 @@ DECLARE
|
|
|
2554
2663
|
can jsonb;
|
|
2555
2664
|
changed jsonb;
|
|
2556
2665
|
stuck text;
|
|
2666
|
+
secrets jsonb;
|
|
2667
|
+
look text;
|
|
2668
|
+
role_ text;
|
|
2669
|
+
tried jsonb;
|
|
2670
|
+
i integer;
|
|
2557
2671
|
BEGIN
|
|
2558
2672
|
FOR member IN SELECT * FROM jsonb_array_elements(__KN__.memberships(tables)) LOOP
|
|
2559
2673
|
SELECT (s->>'attempt')::integer INTO shape
|
|
@@ -2571,37 +2685,52 @@ BEGIN
|
|
|
2571
2685
|
CONTINUE WHEN theirs IS NULL;
|
|
2572
2686
|
|
|
2573
2687
|
-- Their team, as the first person's own membership names it.
|
|
2574
|
-
|
|
2688
|
+
team := '{}'::jsonb;
|
|
2575
2689
|
FOR key IN SELECT * FROM jsonb_array_elements(member->'keys') LOOP
|
|
2576
2690
|
FOR name IN SELECT jsonb_array_elements_text(key->'columns') LOOP
|
|
2577
2691
|
IF name <> member->>'person' THEN
|
|
2578
|
-
|
|
2692
|
+
team := jsonb_set(team, ARRAY[name], coalesce(theirs->name, 'null'::jsonb));
|
|
2579
2693
|
END IF;
|
|
2580
2694
|
END LOOP;
|
|
2581
2695
|
END LOOP;
|
|
2582
|
-
|
|
2583
|
-
|
|
2696
|
+
parent_key := member->'keys'->0;
|
|
2697
|
+
parent := parent_key->>'refTable';
|
|
2584
2698
|
|
|
2585
|
-
--
|
|
2699
|
+
-- Putting the other person in that team, as each role in turn. Built up
|
|
2700
|
+
-- front, before anybody changes role.
|
|
2701
|
+
joins := '{}'::jsonb;
|
|
2586
2702
|
refused := NULL;
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2703
|
+
FOR role_ IN SELECT jsonb_array_elements_text(member->'ladder') LOOP
|
|
2704
|
+
BEGIN
|
|
2705
|
+
overrides := jsonb_set(team, ARRAY[member->>'role'], to_jsonb(role_));
|
|
2706
|
+
row_ := __KN__.row_for(source, member->'table', __KN__.user_b(), '9', overrides, shape);
|
|
2707
|
+
joins := jsonb_set(joins, ARRAY[role_],
|
|
2708
|
+
to_jsonb(__KN__.insert_statement(source, member->'table'->>'name', row_)));
|
|
2709
|
+
EXCEPTION WHEN OTHERS THEN
|
|
2710
|
+
IF role_ = member->>'lowest' THEN refused := SQLERRM; END IF;
|
|
2711
|
+
END;
|
|
2712
|
+
END LOOP;
|
|
2713
|
+
join_ := joins->>(member->>'lowest');
|
|
2714
|
+
|
|
2715
|
+
-- Whether they can be put in the team at all, tried once on its own.
|
|
2716
|
+
IF refused IS NULL THEN
|
|
2590
2717
|
BEGIN
|
|
2591
2718
|
EXECUTE join_;
|
|
2592
2719
|
RAISE EXCEPTION 'kryptheon: undoing the join' USING ERRCODE = 'KN001';
|
|
2593
|
-
EXCEPTION
|
|
2594
|
-
|
|
2720
|
+
EXCEPTION
|
|
2721
|
+
WHEN SQLSTATE 'KN001' THEN
|
|
2722
|
+
NULL;
|
|
2723
|
+
WHEN OTHERS THEN
|
|
2724
|
+
refused := SQLERRM;
|
|
2595
2725
|
END;
|
|
2596
|
-
|
|
2597
|
-
refused := SQLERRM;
|
|
2598
|
-
END;
|
|
2726
|
+
END IF;
|
|
2599
2727
|
IF refused IS NOT NULL THEN
|
|
2600
2728
|
-- Said once, on the table that would not take them.
|
|
2601
2729
|
blocked := blocked || jsonb_build_array(jsonb_build_object(
|
|
2602
2730
|
'table', member->'table'->>'name',
|
|
2603
2731
|
'key', 'role:' || (member->'table'->>'name') || ':' || (member->>'lowest'),
|
|
2604
|
-
'why',
|
|
2732
|
+
'why', 'as a "' || (member->>'lowest') || '" in ' || (member->'table'->>'name') ||
|
|
2733
|
+
': I could not add one: ' || refused));
|
|
2605
2734
|
CONTINUE;
|
|
2606
2735
|
END IF;
|
|
2607
2736
|
|
|
@@ -2610,54 +2739,141 @@ BEGIN
|
|
|
2610
2739
|
WHERE s->>'table' = tab->>'name');
|
|
2611
2740
|
CONTINUE WHEN done ? (tab->>'name');
|
|
2612
2741
|
done := done || to_jsonb(tab->>'name');
|
|
2742
|
+
SELECT (s->>'attempt')::integer INTO shape
|
|
2743
|
+
FROM jsonb_array_elements(coalesce(seeded, '[]'::jsonb)) s
|
|
2744
|
+
WHERE s->>'table' = tab->>'name';
|
|
2745
|
+
shape := coalesce(shape, 0);
|
|
2613
2746
|
finding_key := 'role:' || (tab->>'name') || ':' || (member->>'lowest');
|
|
2614
2747
|
owner := __KN__.owner_column(tab);
|
|
2615
|
-
|
|
2616
|
-
--
|
|
2617
|
-
|
|
2618
|
-
|
|
2748
|
+
|
|
2749
|
+
-- A table that names the team itself, and the team it is to name.
|
|
2750
|
+
SELECT k INTO to_team
|
|
2751
|
+
FROM jsonb_array_elements(__KN__.foreign_keys(tab) || __KN__.implied_keys(tab, tables))
|
|
2752
|
+
WITH ORDINALITY AS e(k, pos)
|
|
2753
|
+
WHERE k->>'refTable' = parent
|
|
2754
|
+
AND jsonb_array_length(k->'columns') = jsonb_array_length(parent_key->'refColumns')
|
|
2755
|
+
ORDER BY pos LIMIT 1;
|
|
2756
|
+
IF NOT FOUND THEN to_team := NULL; END IF;
|
|
2757
|
+
pointed := '{}'::jsonb;
|
|
2758
|
+
IF to_team IS NOT NULL THEN
|
|
2759
|
+
FOR i IN 0 .. jsonb_array_length(to_team->'columns') - 1 LOOP
|
|
2760
|
+
pointed := jsonb_set(pointed, ARRAY[to_team->'columns'->>i], coalesce(team->(
|
|
2761
|
+
SELECT parent_key->'columns'->>((p.pos - 1)::integer)
|
|
2762
|
+
FROM jsonb_array_elements_text(parent_key->'refColumns') WITH ORDINALITY AS p(c, pos)
|
|
2763
|
+
WHERE p.c = to_team->'refColumns'->>i), 'null'::jsonb));
|
|
2764
|
+
END LOOP;
|
|
2765
|
+
END IF;
|
|
2766
|
+
|
|
2767
|
+
-- Never their own rows, and only the team they joined, where the table
|
|
2768
|
+
-- says which team a row is in.
|
|
2769
|
+
where_ := ARRAY[]::text[];
|
|
2770
|
+
IF owner IS NOT NULL THEN
|
|
2771
|
+
where_ := where_ || (__KN__.always_quote(owner) || ' IS DISTINCT FROM ' || quote_literal(__KN__.user_b()));
|
|
2772
|
+
END IF;
|
|
2773
|
+
FOR name IN SELECT jsonb_object_keys(pointed) ORDER BY 1 LOOP
|
|
2774
|
+
where_ := where_ || (__KN__.always_quote(name) || ' = ' || quote_literal(pointed->>name));
|
|
2775
|
+
END LOOP;
|
|
2776
|
+
not_theirs := CASE WHEN array_length(where_, 1) IS NULL THEN ''
|
|
2777
|
+
ELSE ' WHERE ' || array_to_string(where_, ' AND ') END;
|
|
2619
2778
|
target := __KN__.always_quote(source) || '.' || __KN__.always_quote(tab->>'name');
|
|
2620
2779
|
column_ := __KN__.always_quote(__KN__.first_writable(tab));
|
|
2780
|
+
moves := jsonb_build_array(
|
|
2781
|
+
jsonb_build_object('what', 'change',
|
|
2782
|
+
'statement', 'UPDATE ' || target || ' SET ' || column_ || ' = ' || column_ || not_theirs),
|
|
2783
|
+
jsonb_build_object('what', 'delete', 'statement', 'DELETE FROM ' || target || not_theirs));
|
|
2784
|
+
|
|
2785
|
+
-- Adding a row to the team, said only of a role whose own name says it
|
|
2786
|
+
-- may only look, and written under the other person's name: a rule that
|
|
2787
|
+
-- lets a member add a row only as themselves is them speaking for
|
|
2788
|
+
-- themselves.
|
|
2789
|
+
IF __KN__.look_only(member->>'lowest') AND to_team IS NOT NULL THEN
|
|
2790
|
+
BEGIN
|
|
2791
|
+
row_ := __KN__.row_for(source, tab, __KN__.user_a(), '11', pointed, shape);
|
|
2792
|
+
moves := jsonb_build_array(jsonb_build_object('what', 'add',
|
|
2793
|
+
'statement', __KN__.insert_statement(source, tab->>'name', row_))) || moves;
|
|
2794
|
+
EXCEPTION WHEN OTHERS THEN
|
|
2795
|
+
NULL;
|
|
2796
|
+
END;
|
|
2797
|
+
END IF;
|
|
2798
|
+
|
|
2621
2799
|
can := '[]'::jsonb;
|
|
2622
2800
|
changed := '{}'::jsonb;
|
|
2623
2801
|
stuck := NULL;
|
|
2624
|
-
FOR move IN
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
(2, 'delete', 'DELETE FROM ' || target || not_theirs)) AS m(n, what, statement)
|
|
2628
|
-
ORDER BY n
|
|
2629
|
-
LOOP
|
|
2630
|
-
before_ := __KN__.try_write('authenticated', __KN__.user_b(), move.statement);
|
|
2631
|
-
after_ := __KN__.try_write_joined('authenticated', __KN__.user_b(), move.statement, join_);
|
|
2802
|
+
FOR move IN SELECT * FROM jsonb_array_elements(moves) LOOP
|
|
2803
|
+
before_ := __KN__.try_write('authenticated', __KN__.user_b(), move->>'statement');
|
|
2804
|
+
after_ := __KN__.try_write_joined('authenticated', __KN__.user_b(), move->>'statement', join_);
|
|
2632
2805
|
was := __KN__.rows_moved(before_);
|
|
2633
2806
|
now_ := __KN__.rows_moved(after_);
|
|
2634
2807
|
IF was IS NULL OR now_ IS NULL THEN
|
|
2635
2808
|
stuck := CASE WHEN now_ IS NULL THEN after_->>'why' ELSE before_->>'why' END;
|
|
2636
2809
|
ELSIF now_ > was THEN
|
|
2637
|
-
can := can || to_jsonb(move
|
|
2638
|
-
changed := jsonb_set(changed, ARRAY[move
|
|
2810
|
+
can := can || to_jsonb(move->>'what');
|
|
2811
|
+
changed := jsonb_set(changed, ARRAY[move->>'what'], to_jsonb(now_ - was));
|
|
2639
2812
|
END IF;
|
|
2640
2813
|
END LOOP;
|
|
2641
2814
|
|
|
2642
2815
|
IF jsonb_array_length(can) = 0 AND stuck IS NOT NULL THEN
|
|
2643
2816
|
blocked := blocked || jsonb_build_array(jsonb_build_object(
|
|
2644
|
-
'table', tab->>'name', 'key', finding_key,
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2817
|
+
'table', tab->>'name', 'key', finding_key,
|
|
2818
|
+
'why', 'as a "' || (member->>'lowest') || '" in ' || (member->'table'->>'name') || ': ' || stuck));
|
|
2819
|
+
ELSE
|
|
2820
|
+
completed := completed || to_jsonb(finding_key);
|
|
2821
|
+
IF jsonb_array_length(can) > 0 THEN
|
|
2822
|
+
findings := findings || jsonb_build_array(jsonb_build_object(
|
|
2823
|
+
'kind', 'role',
|
|
2824
|
+
'table', tab->>'name',
|
|
2825
|
+
'who', member->>'lowest',
|
|
2826
|
+
'via', member->'table'->>'name',
|
|
2827
|
+
'roleColumn', member->>'role',
|
|
2828
|
+
'parent', parent,
|
|
2829
|
+
'can', can,
|
|
2830
|
+
'changed', changed,
|
|
2831
|
+
'columns', coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c), '[]'::jsonb),
|
|
2832
|
+
'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false)));
|
|
2833
|
+
END IF;
|
|
2660
2834
|
END IF;
|
|
2835
|
+
|
|
2836
|
+
-- Reading what a member should not see, said only of a table holding a
|
|
2837
|
+
-- secret. Every role not in charge, from the lowest up; the first that
|
|
2838
|
+
-- can read is the one named.
|
|
2839
|
+
secrets := __KN__.secret_columns(tab);
|
|
2840
|
+
CONTINUE WHEN jsonb_array_length(secrets) = 0;
|
|
2841
|
+
look := 'SELECT 1 FROM ' || target || not_theirs;
|
|
2842
|
+
was := __KN__.rows_moved(__KN__.try_write('authenticated', __KN__.user_b(), look));
|
|
2843
|
+
tried := '[]'::jsonb;
|
|
2844
|
+
FOR role_ IN SELECT jsonb_array_elements_text(member->'ladder') LOOP
|
|
2845
|
+
read_key := 'teamread:' || (tab->>'name') || ':' || role_;
|
|
2846
|
+
IF joins ? role_ THEN
|
|
2847
|
+
after_ := __KN__.try_write_joined('authenticated', __KN__.user_b(), look, joins->>role_);
|
|
2848
|
+
ELSE
|
|
2849
|
+
after_ := jsonb_build_object('ok', false, 'count', 0, 'why', 'I could not add one');
|
|
2850
|
+
END IF;
|
|
2851
|
+
now_ := __KN__.rows_moved(after_);
|
|
2852
|
+
IF was IS NULL OR now_ IS NULL THEN
|
|
2853
|
+
blocked := blocked || jsonb_build_array(jsonb_build_object(
|
|
2854
|
+
'table', tab->>'name', 'key', read_key,
|
|
2855
|
+
'why', 'as a "' || role_ || '" in ' || (member->'table'->>'name') || ': ' ||
|
|
2856
|
+
CASE WHEN now_ IS NULL THEN after_->>'why' ELSE 'reading it failed before joining' END));
|
|
2857
|
+
EXIT;
|
|
2858
|
+
END IF;
|
|
2859
|
+
completed := completed || to_jsonb(read_key);
|
|
2860
|
+
IF now_ > was THEN
|
|
2861
|
+
findings := findings || jsonb_build_array(jsonb_build_object(
|
|
2862
|
+
'kind', 'teamread',
|
|
2863
|
+
'table', tab->>'name',
|
|
2864
|
+
'who', role_,
|
|
2865
|
+
'via', member->'table'->>'name',
|
|
2866
|
+
'roleColumn', member->>'role',
|
|
2867
|
+
'parent', parent,
|
|
2868
|
+
'readable', now_ - was,
|
|
2869
|
+
'secrets', secrets,
|
|
2870
|
+
'below', tried,
|
|
2871
|
+
'columns', coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c), '[]'::jsonb),
|
|
2872
|
+
'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false)));
|
|
2873
|
+
EXIT;
|
|
2874
|
+
END IF;
|
|
2875
|
+
tried := tried || to_jsonb(role_);
|
|
2876
|
+
END LOOP;
|
|
2661
2877
|
END LOOP;
|
|
2662
2878
|
END LOOP;
|
|
2663
2879
|
|
package/finding.js
CHANGED
|
@@ -89,6 +89,12 @@ function listOf(items) {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/** One of a table's things: workspaces -> workspace, companies -> company. */
|
|
92
|
+
/** The same, with its article: a workspace, an organization. */
|
|
93
|
+
function aOne(table) {
|
|
94
|
+
const one = oneOf(table);
|
|
95
|
+
return (/^[aeiou]/i.test(one) ? 'an ' : 'a ') + one;
|
|
96
|
+
}
|
|
97
|
+
|
|
92
98
|
function oneOf(table) {
|
|
93
99
|
return String(table || 'team').replace(/ies$/, 'y').replace(/s$/, '');
|
|
94
100
|
}
|
|
@@ -110,7 +116,7 @@ function severityOf(finding, contents) {
|
|
|
110
116
|
// because the app is broken for whoever it fails for, but not a break-in.
|
|
111
117
|
return 'HIGH';
|
|
112
118
|
}
|
|
113
|
-
if (finding.kind === 'role' || finding.kind === 'bucket') {
|
|
119
|
+
if (finding.kind === 'role' || finding.kind === 'teamread' || finding.kind === 'bucket') {
|
|
114
120
|
// Something to check, never a confirmed break: whether a viewer may edit,
|
|
115
121
|
// or a bucket may be public, is the owner's decision. So never CRITICAL.
|
|
116
122
|
return 'HIGH';
|
|
@@ -345,9 +351,13 @@ function headlineFor(finding) {
|
|
|
345
351
|
if (finding.kind === 'role') {
|
|
346
352
|
const order = ['delete', 'change', 'add'];
|
|
347
353
|
const does = order.filter((what) => (finding.can || []).includes(what)).map((what) => WRITE_WORDS[what]);
|
|
348
|
-
return 'A "' + finding.who + '" in
|
|
354
|
+
return 'A "' + finding.who + '" in ' + aOne(finding.parent) + ' can ' + listOf(does) + ' your ' +
|
|
349
355
|
finding.table + ' table.';
|
|
350
356
|
}
|
|
357
|
+
if (finding.kind === 'teamread') {
|
|
358
|
+
return 'A "' + finding.who + '" in ' + aOne(finding.parent) + ' can read your ' + finding.table +
|
|
359
|
+
' table, which holds ' + listOf(finding.secrets || []) + '.';
|
|
360
|
+
}
|
|
351
361
|
if (finding.kind === 'bucket') {
|
|
352
362
|
return 'Your ' + finding.table + ' storage bucket is public, but a rule says only some people may read it.';
|
|
353
363
|
}
|
|
@@ -439,19 +449,36 @@ function bodyFor(finding, contents) {
|
|
|
439
449
|
// edit is a hole in one app and the design of another.
|
|
440
450
|
const did = [];
|
|
441
451
|
const rows = (n) => n + (n === 1 ? ' row' : ' rows');
|
|
452
|
+
if ((finding.can || []).includes('add')) did.push('added ' + rows((finding.changed || {}).add || 1));
|
|
442
453
|
if ((finding.can || []).includes('change')) did.push('changed ' + rows((finding.changed || {}).change || 1));
|
|
443
454
|
if ((finding.can || []).includes('delete')) did.push('deleted ' + rows((finding.changed || {}).delete || 1));
|
|
444
455
|
const place = oneOf(finding.parent);
|
|
445
456
|
return (
|
|
446
457
|
'I put one of my fake people into the other one\'s ' + place + ' as a "' + finding.who +
|
|
447
458
|
'" - the lowest role in ' + finding.via + '.' + finding.roleColumn + ' - and, signed in as them, I ' +
|
|
448
|
-
listOf(did) + ' in ' + finding.table + '
|
|
459
|
+
listOf(did) + ' in ' + finding.table + ' for that ' + place + ', on a copy of your app. ' +
|
|
449
460
|
'Before joining, they could not. Every one of those was undone straight away. Whether a "' + finding.who +
|
|
450
461
|
'" may do this is your decision: if it is meant to, nothing needs fixing; if a "' + finding.who +
|
|
451
462
|
'" should only look, the rules on this table let them do more.'
|
|
452
463
|
);
|
|
453
464
|
}
|
|
454
465
|
|
|
466
|
+
if (finding.kind === 'teamread') {
|
|
467
|
+
// Said as what was read and by whom, and which roles below it could not:
|
|
468
|
+
// a dispatcher seeing a key may be the design, a viewer rarely is.
|
|
469
|
+
const place = oneOf(finding.parent);
|
|
470
|
+
const n = finding.readable || 1;
|
|
471
|
+
const below = (finding.below || []).map((r) => '"' + r + '"');
|
|
472
|
+
return (
|
|
473
|
+
'I put one of my fake people into the other one\'s ' + place + ' as a "' + finding.who + '" and, signed ' +
|
|
474
|
+
'in as them, read ' + n + (n === 1 ? ' row' : ' rows') + ' of ' + finding.table + ' for that ' + place +
|
|
475
|
+
', on a copy of your app - including ' + listOf(finding.secrets || []) + '. Before joining, they could not.' +
|
|
476
|
+
(below.length ? ' As ' + listOf(below).replace(/ and /, ' or ') + ' they could not read it either.' : '') +
|
|
477
|
+
' Nothing was changed. Whether a "' + finding.who + '" should see these is your decision: if not, a read ' +
|
|
478
|
+
'rule on this table lets them.'
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
455
482
|
if (finding.kind === 'bucket') {
|
|
456
483
|
// Said as what was read, never as a file taken: no file was opened.
|
|
457
484
|
return (
|
|
@@ -747,9 +774,9 @@ function fixPromptFor(finding) {
|
|
|
747
774
|
? [
|
|
748
775
|
'My app may have a permissions problem - please check it rather than assume it.',
|
|
749
776
|
'',
|
|
750
|
-
'In my database, a member of
|
|
777
|
+
'In my database, a member of ' + aOne(finding.parent) + ' whose role in "' + finding.via + '"."' +
|
|
751
778
|
finding.roleColumn + '" is "' + finding.who + '" can ' +
|
|
752
|
-
listOf((finding.can || []).map((what) => ({ change: 'change', delete: 'delete' })[what])) +
|
|
779
|
+
listOf((finding.can || []).map((what) => ({ add: 'add', change: 'change', delete: 'delete' })[what])) +
|
|
753
780
|
' rows of that ' + oneOf(finding.parent) + ' in the "' + finding.table + '" table. This was proved ' +
|
|
754
781
|
'on a copy of the database: a "' + finding.who + '" was added, and the writes went through.',
|
|
755
782
|
'',
|
|
@@ -764,6 +791,25 @@ function fixPromptFor(finding) {
|
|
|
764
791
|
'',
|
|
765
792
|
'Then check every other table where members can write for the same thing.',
|
|
766
793
|
]
|
|
794
|
+
: finding.kind === 'teamread'
|
|
795
|
+
? [
|
|
796
|
+
'My app may have a permissions problem - please check it rather than assume it.',
|
|
797
|
+
'',
|
|
798
|
+
'In my database, a member of ' + aOne(finding.parent) + ' whose role in "' + finding.via + '"."' +
|
|
799
|
+
finding.roleColumn + '" is "' + finding.who + '" can read the "' + finding.table + '" table of that ' +
|
|
800
|
+
oneOf(finding.parent) + ', including ' + listOf((finding.secrets || []).map((c) => '"' + c + '"')) +
|
|
801
|
+
'. This was proved on a copy of the database: a "' + finding.who + '" was added, and the rows came back.',
|
|
802
|
+
'',
|
|
803
|
+
'First tell me: should a "' + finding.who + '" see these? If yes, change nothing.',
|
|
804
|
+
'',
|
|
805
|
+
'If not, make the rules on "' + finding.table + '" that cover SELECT or ALL check the member\'s role as ' +
|
|
806
|
+
'well as membership. Postgres lets a read through if ANY permissive policy allows it, so a strict rule ' +
|
|
807
|
+
'beside a loose one does nothing: list every such policy on "' + finding.table + '" and narrow or drop ' +
|
|
808
|
+
'the loose one. If members need the other columns, a view or function that leaves the secret ones out ' +
|
|
809
|
+
'is often the better fix.',
|
|
810
|
+
'',
|
|
811
|
+
'Then check every other table that holds tokens, keys, secrets or payloads for the same thing.',
|
|
812
|
+
]
|
|
767
813
|
: finding.kind === 'bucket'
|
|
768
814
|
? [
|
|
769
815
|
'My app may have a storage problem - please check it rather than assume it.',
|
|
@@ -958,7 +1004,7 @@ function fixPromptFor(finding) {
|
|
|
958
1004
|
|
|
959
1005
|
// Found and said, but never claimed as a break: the owner decides. Counted
|
|
960
1006
|
// apart in the report, never towards the exit code or a clean re-check.
|
|
961
|
-
const TO_CHECK = ['privileged', 'role', 'bucket'];
|
|
1007
|
+
const TO_CHECK = ['privileged', 'role', 'teamread', 'bucket'];
|
|
962
1008
|
|
|
963
1009
|
/** Everything the report needs about one thing the attack found. */
|
|
964
1010
|
function describe(finding) {
|
|
@@ -994,6 +1040,8 @@ function describe(finding) {
|
|
|
994
1040
|
can: finding.can,
|
|
995
1041
|
via: finding.via,
|
|
996
1042
|
roleColumn: finding.roleColumn,
|
|
1043
|
+
readable: finding.readable,
|
|
1044
|
+
secrets: finding.secrets,
|
|
997
1045
|
bucket: finding.bucket,
|
|
998
1046
|
proof: finding.kind === 'recursive'
|
|
999
1047
|
? 'Using ' + listOf((finding.reads || []).map((t) => '"' + t + '"')) + ' stopped with ' +
|
|
@@ -1004,8 +1052,11 @@ function describe(finding) {
|
|
|
1004
1052
|
? 'As a "' + finding.who + '" who had just joined, I ' +
|
|
1005
1053
|
listOf((finding.can || []).map((what) => {
|
|
1006
1054
|
const n = (finding.changed || {})[what] || 1;
|
|
1007
|
-
return (
|
|
1055
|
+
return ({ add: 'added ', change: 'changed ', delete: 'deleted ' })[what] + n + (n === 1 ? ' row' : ' rows');
|
|
1008
1056
|
})) + ' in "' + finding.table + '" that I could not touch before joining. All of it was undone.'
|
|
1057
|
+
: finding.kind === 'teamread'
|
|
1058
|
+
? 'As a "' + finding.who + '" who had just joined, I read ' + (finding.readable || 1) +
|
|
1059
|
+
((finding.readable || 1) === 1 ? ' row' : ' rows') + ' of "' + finding.table + '" that I could not read before joining.'
|
|
1009
1060
|
: finding.kind === 'bucket'
|
|
1010
1061
|
? 'I read that "' + finding.bucket + '" is public and that ' + listOf((finding.rules || []).map((r) => '"' + r + '"')) +
|
|
1011
1062
|
' says who may read it. I did not open any file.'
|
|
@@ -1113,7 +1164,7 @@ function describeAll(findings) {
|
|
|
1113
1164
|
// Writes above reads: a table somebody emptied is worse than one they read.
|
|
1114
1165
|
// A looping rule sits with the reads: it breaks the app for everyone it
|
|
1115
1166
|
// fails for, which matters more than a duplicate or a stray row.
|
|
1116
|
-
const byKind = { writable: 0, exposed: 1, crossed: 2, recursive: 2.5, duplicated: 3, orphaned: 4, role: 4.5, bucket: 4.7, privileged: 5 };
|
|
1167
|
+
const byKind = { writable: 0, exposed: 1, crossed: 2, recursive: 2.5, duplicated: 3, orphaned: 4, role: 4.5, teamread: 4.6, bucket: 4.7, privileged: 5 };
|
|
1117
1168
|
const rank = (d) => (d.severity === 'CRITICAL' ? 0 : 1) * 10 + (byKind[d.kind] === undefined ? 9 : byKind[d.kind]);
|
|
1118
1169
|
return described.sort((a, b) => rank(a) - rank(b));
|
|
1119
1170
|
}
|
package/package.json
CHANGED
package/recheck.js
CHANGED
|
@@ -53,6 +53,53 @@ function proofsOf(list) {
|
|
|
53
53
|
return (list || []).reduce((all, item) => all.concat(item.members && item.members.length ? item.members : [item]), []);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/*
|
|
57
|
+
* Whether the earlier run was on this database at all.
|
|
58
|
+
*
|
|
59
|
+
* Found on a blind test (HelixOps): the repository's database was scanned,
|
|
60
|
+
* then the production one from the same folder, and the re-check reported the
|
|
61
|
+
* six differences as "NEW problems" and said "A fix can open something else".
|
|
62
|
+
* Nothing had been fixed; it was another database. A run now says where it
|
|
63
|
+
* was; one saved before that is judged by the tables it attacked.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
// Attacks named for a table, as opposed to a function or a bucket.
|
|
67
|
+
const ON_A_TABLE = /^(exposed|crossed|writable|recursive|orphaned|duplicated|role|teamread):/;
|
|
68
|
+
|
|
69
|
+
/** The tables a run looked at: as it said, or else as its attacks name them. */
|
|
70
|
+
function tablesOf(run, said) {
|
|
71
|
+
if (said) return run.where.tables;
|
|
72
|
+
return Array.from(new Set(((run && run.attempted) || []).filter((key) => ON_A_TABLE.test(String(key)))
|
|
73
|
+
.map((key) => String(key).split(':')[1])));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A database, said the way a person would recognise it. */
|
|
77
|
+
function placeOf(where) {
|
|
78
|
+
if (!where) return null;
|
|
79
|
+
return '"' + where.database + '"' + (where.host ? ' on ' + where.host + (where.port ? ':' + where.port : '') : '') +
|
|
80
|
+
(where.schema && where.schema !== 'public' ? ', schema "' + where.schema + '"' : '');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function elsewhereOf(before, after) {
|
|
84
|
+
const was = before && before.where;
|
|
85
|
+
const now = after && after.where;
|
|
86
|
+
const said = Boolean(was && now && Array.isArray(was.tables) && Array.isArray(now.tables));
|
|
87
|
+
const a = new Set(tablesOf(before, said));
|
|
88
|
+
const b = new Set(tablesOf(after, said));
|
|
89
|
+
const onlyBefore = Array.from(a).filter((t) => !b.has(t)).sort();
|
|
90
|
+
const onlyNow = Array.from(b).filter((t) => !a.has(t)).sort();
|
|
91
|
+
const union = new Set(Array.from(a).concat(Array.from(b))).size;
|
|
92
|
+
const shared = union - onlyBefore.length - onlyNow.length;
|
|
93
|
+
const otherDatabase = Boolean(was && now) && (was.database !== now.database ||
|
|
94
|
+
(was.host || null) !== (now.host || null) || (was.port || null) !== (now.port || null) || was.schema !== now.schema);
|
|
95
|
+
// Fewer than half the tables in common: not the same app any more. Only
|
|
96
|
+
// said when both runs name tables: a run that names none says nothing.
|
|
97
|
+
const otherShape = a.size > 0 && b.size > 0 && shared * 2 < union;
|
|
98
|
+
if (!otherDatabase && !otherShape) return null;
|
|
99
|
+
return { otherDatabase: otherDatabase, otherShape: otherShape, was: placeOf(was), now: placeOf(now),
|
|
100
|
+
onlyBefore: onlyBefore, onlyNow: onlyNow };
|
|
101
|
+
}
|
|
102
|
+
|
|
56
103
|
function compare(before, after) {
|
|
57
104
|
const was = proofsOf(before && before.findings);
|
|
58
105
|
const now = proofsOf(after && after.findings);
|
|
@@ -120,9 +167,13 @@ function compare(before, after) {
|
|
|
120
167
|
|
|
121
168
|
const wasByKey = new Set(was.map(keyOf));
|
|
122
169
|
const newlyBroken = now.filter((item) => !wasByKey.has(keyOf(item)));
|
|
170
|
+
const elsewhere = elsewhereOf(before, after);
|
|
123
171
|
|
|
124
172
|
return {
|
|
125
173
|
stopped: null,
|
|
174
|
+
// Set when the earlier run was on another database, or on a very
|
|
175
|
+
// different set of tables: then this is a comparison, not a re-check.
|
|
176
|
+
elsewhere: elsewhere,
|
|
126
177
|
fixed: fixed,
|
|
127
178
|
stillOpen: stillOpen,
|
|
128
179
|
unverifiable: unverifiable,
|
|
@@ -138,7 +189,9 @@ function compare(before, after) {
|
|
|
138
189
|
// unverifiable, so "the other two are empty" already means "all of them
|
|
139
190
|
// were fixed" - stating that a third time as a count only made each guard
|
|
140
191
|
// able to cover for the others, which is how a broken guard stays hidden.
|
|
192
|
+
// And never from two different databases: nothing was fixed between them.
|
|
141
193
|
allClear:
|
|
194
|
+
!elsewhere &&
|
|
142
195
|
!stillOpen.length &&
|
|
143
196
|
!unverifiable.length &&
|
|
144
197
|
!newlyBroken.length &&
|
|
@@ -173,11 +226,58 @@ function whatOf(item) {
|
|
|
173
226
|
if (item.kind === 'orphaned') return 'rows left pointing at nothing';
|
|
174
227
|
if (item.kind === 'privileged') return 'a function anyone can call';
|
|
175
228
|
if (item.kind === 'role') return 'changed by a "' + (item.who || 'viewer') + '"';
|
|
229
|
+
if (item.kind === 'teamread') return 'read by a "' + (item.who || 'viewer') + '"';
|
|
176
230
|
if (item.kind === 'bucket') return 'a public storage bucket';
|
|
177
231
|
if (item.kind === 'recursive') return 'a rule that refers to itself';
|
|
178
232
|
return item.kind;
|
|
179
233
|
}
|
|
180
234
|
|
|
235
|
+
/** Wraps one paragraph for the screen, indented the way these lines are. */
|
|
236
|
+
function wrapped(text) {
|
|
237
|
+
const out = [];
|
|
238
|
+
let current = '';
|
|
239
|
+
for (const word of String(text).split(/\s+/)) {
|
|
240
|
+
if ((current + ' ' + word).trim().length > 70) {
|
|
241
|
+
out.push(' ' + current.trim());
|
|
242
|
+
current = word;
|
|
243
|
+
} else {
|
|
244
|
+
current = (current + ' ' + word).trim();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (current) out.push(' ' + current.trim());
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Up to eight names, and how many more. */
|
|
252
|
+
function someOf(names) {
|
|
253
|
+
const shown = names.slice(0, 8).join(', ');
|
|
254
|
+
return names.length > 8 ? shown + ' and ' + (names.length - 8) + ' more' : shown;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Said first when the earlier run was somewhere else: which two databases,
|
|
259
|
+
* and how their tables differ - so nothing after it reads as a fix.
|
|
260
|
+
*/
|
|
261
|
+
function elsewhereLines(elsewhere) {
|
|
262
|
+
const lines = [];
|
|
263
|
+
const tables = (n) => n + (n === 1 ? ' table' : ' tables');
|
|
264
|
+
lines.push(' This is not a re-check of the same database.');
|
|
265
|
+
lines.push('');
|
|
266
|
+
const where = elsewhere.otherDatabase
|
|
267
|
+
? 'The earlier run kept for this folder was on ' + elsewhere.was + ', and this one is on ' + elsewhere.now + '.'
|
|
268
|
+
: 'The earlier run kept for this folder looked at a very different set of tables.';
|
|
269
|
+
wrapped(where + ' So what follows compares two databases: nothing in it means a fix worked, ' +
|
|
270
|
+
'and nothing in it means a fix broke something.').forEach((l) => lines.push(l));
|
|
271
|
+
lines.push('');
|
|
272
|
+
const shape = [];
|
|
273
|
+
if (elsewhere.onlyBefore.length) shape.push(tables(elsewhere.onlyBefore.length) + ' only in the earlier one (' + someOf(elsewhere.onlyBefore) + ')');
|
|
274
|
+
if (elsewhere.onlyNow.length) shape.push(tables(elsewhere.onlyNow.length) + ' only in this one (' + someOf(elsewhere.onlyNow) + ')');
|
|
275
|
+
wrapped(shape.length ? 'How their tables differ: ' + shape.join('; ') + '.' : 'Both have the same tables.')
|
|
276
|
+
.forEach((l) => lines.push(l));
|
|
277
|
+
lines.push('');
|
|
278
|
+
return lines;
|
|
279
|
+
}
|
|
280
|
+
|
|
181
281
|
/** What the re-check says, in the order it matters. */
|
|
182
282
|
function describe(result) {
|
|
183
283
|
const lines = [''];
|
|
@@ -190,26 +290,34 @@ function describe(result) {
|
|
|
190
290
|
return lines;
|
|
191
291
|
}
|
|
192
292
|
|
|
293
|
+
const elsewhere = result.elsewhere;
|
|
294
|
+
if (elsewhere) elsewhereLines(elsewhere).forEach((l) => lines.push(l));
|
|
295
|
+
|
|
193
296
|
if (result.fixed.length) {
|
|
194
|
-
|
|
297
|
+
const n = result.fixed.length;
|
|
298
|
+
lines.push(elsewhere
|
|
299
|
+
? ' ' + n + ' found in the earlier run and not here:'
|
|
300
|
+
: ' ' + n + (n === 1 ? ' problem is' : ' problems are') + ' fixed:');
|
|
195
301
|
lines.push('');
|
|
196
302
|
for (const item of result.fixed) {
|
|
197
303
|
// A function is never called, only read - so its fix is said as what
|
|
198
304
|
// was seen, not as an attack that was refused. Found on a fix test.
|
|
199
305
|
const how = item.kind === 'privileged'
|
|
200
|
-
? 'I looked
|
|
306
|
+
? (elsewhere ? 'I looked here, and a visitor with no account cannot call it.'
|
|
307
|
+
: 'I looked again, and a visitor with no account can no longer call it.')
|
|
201
308
|
: item.kind === 'bucket'
|
|
202
|
-
? 'I looked again, and the bucket is no longer public.'
|
|
309
|
+
? (elsewhere ? 'I looked here, and the bucket is not public.' : 'I looked again, and the bucket is no longer public.')
|
|
203
310
|
: item.kind === 'recursive'
|
|
204
|
-
? 'I read it
|
|
205
|
-
|
|
311
|
+
? (elsewhere ? 'I read it here, and its rules answered.'
|
|
312
|
+
: 'I read it again, and its rules answered instead of stopping with an error.')
|
|
313
|
+
: (elsewhere ? 'I ran the same attack here and it was refused.' : 'I ran the same attack again and it was refused.');
|
|
206
314
|
lines.push(' ' + item.table + ' (' + whatOf(item) + ') - ' + how);
|
|
207
315
|
}
|
|
208
316
|
lines.push('');
|
|
209
317
|
}
|
|
210
318
|
|
|
211
319
|
if (result.stillOpen.length) {
|
|
212
|
-
lines.push(' ' + result.stillOpen.length + ' still open:');
|
|
320
|
+
lines.push(' ' + result.stillOpen.length + (elsewhere ? ' found in both:' : ' still open:'));
|
|
213
321
|
lines.push('');
|
|
214
322
|
for (const item of result.stillOpen) {
|
|
215
323
|
lines.push(' ' + item.table + ' - ' + item.headline);
|
|
@@ -220,32 +328,44 @@ function describe(result) {
|
|
|
220
328
|
// Deliberately not under "fixed", and deliberately not silent. This is the
|
|
221
329
|
// one a person would otherwise read as good news.
|
|
222
330
|
if (result.unverifiable.length) {
|
|
223
|
-
lines.push(' ' + result.unverifiable.length + ' I could NOT confirm:');
|
|
331
|
+
lines.push(' ' + result.unverifiable.length + (elsewhere ? ' I could NOT compare:' : ' I could NOT confirm:'));
|
|
224
332
|
lines.push('');
|
|
225
333
|
for (const item of result.unverifiable) {
|
|
226
334
|
lines.push(' ' + item.table + ' (' + whatOf(item) + ') - ' + item.why);
|
|
227
335
|
}
|
|
228
336
|
lines.push('');
|
|
229
|
-
|
|
230
|
-
|
|
337
|
+
if (elsewhere) {
|
|
338
|
+
lines.push(' These were found in the earlier run, and the same attack did not run');
|
|
339
|
+
lines.push(' here, so whether they are here too is unknown.');
|
|
340
|
+
} else {
|
|
341
|
+
lines.push(' These are not fixed and not broken - they are unknown. The problem');
|
|
342
|
+
lines.push(' stopped showing up, but not because the attack was refused.');
|
|
343
|
+
}
|
|
231
344
|
lines.push('');
|
|
232
345
|
}
|
|
233
346
|
|
|
234
347
|
if (result.newlyBroken.length) {
|
|
235
|
-
|
|
236
|
-
|
|
348
|
+
const n = result.newlyBroken.length;
|
|
349
|
+
lines.push(elsewhere
|
|
350
|
+
? ' ' + n + ' found here and not in the earlier run:'
|
|
351
|
+
: ' ' + n + ' NEW ' + (n === 1 ? 'problem' : 'problems') + ' that were not there before:');
|
|
237
352
|
lines.push('');
|
|
238
353
|
for (const item of result.newlyBroken) {
|
|
239
354
|
lines.push(' ' + item.table + ' - ' + item.headline);
|
|
240
355
|
}
|
|
241
356
|
lines.push('');
|
|
242
|
-
|
|
243
|
-
|
|
357
|
+
if (elsewhere) {
|
|
358
|
+
lines.push(' These are differences between the two databases, not something a fix');
|
|
359
|
+
lines.push(' opened.');
|
|
360
|
+
} else {
|
|
361
|
+
lines.push(' A fix can open something else. This is why the re-check looks at the');
|
|
362
|
+
lines.push(' whole app again and not only at what it was asked about.');
|
|
363
|
+
}
|
|
244
364
|
lines.push('');
|
|
245
365
|
}
|
|
246
366
|
|
|
247
367
|
if (!result.fixed.length && !result.stillOpen.length && !result.unverifiable.length && !result.newlyBroken.length) {
|
|
248
|
-
lines.push(' Nothing to re-check - there was nothing open.');
|
|
368
|
+
lines.push(elsewhere ? ' Neither run found anything open.' : ' Nothing to re-check - there was nothing open.');
|
|
249
369
|
lines.push('');
|
|
250
370
|
}
|
|
251
371
|
|
|
@@ -266,6 +386,7 @@ function describe(result) {
|
|
|
266
386
|
|
|
267
387
|
module.exports = {
|
|
268
388
|
keyOf: keyOf,
|
|
389
|
+
elsewhereOf: elsewhereOf,
|
|
269
390
|
compare: compare,
|
|
270
391
|
describe: describe,
|
|
271
392
|
badgeLines: badgeLines,
|
package/scan.js
CHANGED
|
@@ -416,8 +416,21 @@ async function scan(client, sourceSchema, options) {
|
|
|
416
416
|
// copy, the way seeding did.
|
|
417
417
|
const tagged = taggerFor(plan, theirs);
|
|
418
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
|
+
|
|
419
425
|
return {
|
|
420
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
|
+
},
|
|
421
434
|
attacksRun: attempted.length,
|
|
422
435
|
notChecked: notChecked,
|
|
423
436
|
attempted: attempted,
|
|
@@ -786,8 +799,8 @@ function report(result) {
|
|
|
786
799
|
if (toVerify.length) {
|
|
787
800
|
// A viewer who can edit was really tried; a function or a bucket was only
|
|
788
801
|
// read. Either way it is the owner's call, and the line says which.
|
|
789
|
-
const tried = toVerify.some((f) => f.kind === 'role');
|
|
790
|
-
const spotted = toVerify.some((f) => f.kind !== 'role');
|
|
802
|
+
const tried = toVerify.some((f) => f.kind === 'role' || f.kind === 'teamread');
|
|
803
|
+
const spotted = toVerify.some((f) => f.kind !== 'role' && f.kind !== 'teamread');
|
|
791
804
|
const it = toVerify.length === 1 ? 'it' : 'them';
|
|
792
805
|
line(' ' + toVerify.length + ' thing' + (toVerify.length === 1 ? '' : 's') + ' to check - ' +
|
|
793
806
|
(spotted && !tried ? 'I did not attack ' + it + ', only spotted the risk.'
|
|
@@ -912,7 +925,9 @@ async function main() {
|
|
|
912
925
|
if (result.findings.length) report(result);
|
|
913
926
|
else notTestedLines(result).forEach(line);
|
|
914
927
|
saveRun(file, result);
|
|
915
|
-
|
|
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;
|
|
916
931
|
} finally {
|
|
917
932
|
await client.end();
|
|
918
933
|
}
|
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, lowest: lowest, ladder: ladder };
|
|
271
293
|
}
|
|
272
294
|
return null;
|
|
273
295
|
}
|
|
@@ -303,7 +325,7 @@ 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, lowest: role.lowest, ladder: role.ladder, keys: keys });
|
|
307
329
|
}
|
|
308
330
|
// A table named for members first: it is the one a team rule asks.
|
|
309
331
|
const named = (m) => (/member/i.test(m.table.name) ? 0 : 1);
|
|
@@ -325,6 +347,11 @@ async function teammate(client, schema, tables, seeded) {
|
|
|
325
347
|
const blocked = [];
|
|
326
348
|
const shapeFor = new Map((seeded || []).map((entry) => [entry.table, entry.attempt || 0]));
|
|
327
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
|
+
};
|
|
328
355
|
|
|
329
356
|
for (const member of memberships(tables)) {
|
|
330
357
|
if (!shapeFor.has(member.table.name)) continue;
|
|
@@ -339,22 +366,32 @@ async function teammate(client, schema, tables, seeded) {
|
|
|
339
366
|
if (!theirs) continue;
|
|
340
367
|
|
|
341
368
|
// Their team, as the first person's own membership names it.
|
|
342
|
-
const
|
|
343
|
-
overrides[member.role] = member.lowest;
|
|
369
|
+
const team = {};
|
|
344
370
|
for (const key of member.keys) {
|
|
345
|
-
key.columns.forEach((name) => { if (name !== member.person)
|
|
371
|
+
key.columns.forEach((name) => { if (name !== member.person) team[name] = theirs[name]; });
|
|
346
372
|
}
|
|
347
|
-
const
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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;
|
|
352
381
|
const row = await attack.rowFor(client, schema, member.table, attack.USER_B, 9, overrides, shapeFor.get(member.table.name));
|
|
353
|
-
join = {
|
|
382
|
+
const join = {
|
|
354
383
|
statement: 'INSERT INTO ' + at + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' +
|
|
355
384
|
row.values.map((_, i) => '$' + (i + 1)).join(', ') + ')',
|
|
356
385
|
values: row.values,
|
|
357
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);
|
|
358
395
|
// Whether they can be put in the team at all, tried once on its own.
|
|
359
396
|
await client.query('BEGIN');
|
|
360
397
|
try {
|
|
@@ -369,7 +406,7 @@ async function teammate(client, schema, tables, seeded) {
|
|
|
369
406
|
// Said once, on the table that would not take them: every team table
|
|
370
407
|
// went untested for the same reason.
|
|
371
408
|
blocked.push({ table: member.table.name, key: 'role:' + member.table.name + ':' + member.lowest,
|
|
372
|
-
why: as + ': I could not add one: ' + refused });
|
|
409
|
+
why: as(member.lowest) + ': I could not add one: ' + refused });
|
|
373
410
|
continue;
|
|
374
411
|
}
|
|
375
412
|
|
|
@@ -378,25 +415,60 @@ async function teammate(client, schema, tables, seeded) {
|
|
|
378
415
|
done.add(table.name);
|
|
379
416
|
const key = 'role:' + table.name + ':' + member.lowest;
|
|
380
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
|
+
}
|
|
381
427
|
// 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
|
-
|
|
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 ') : '';
|
|
384
438
|
const target = quote(schema) + '.' + quote(table.name);
|
|
385
439
|
const column = quote(firstWritable(table));
|
|
386
440
|
const moves = [
|
|
387
|
-
{ what: 'change', statement: 'UPDATE ' + target + ' SET ' + column + ' = ' + column + notTheirs },
|
|
388
|
-
{ what: 'delete', statement: 'DELETE FROM ' + target + notTheirs },
|
|
441
|
+
{ what: 'change', statement: 'UPDATE ' + target + ' SET ' + column + ' = ' + column + notTheirs, values: [] },
|
|
442
|
+
{ what: 'delete', statement: 'DELETE FROM ' + target + notTheirs, values: [] },
|
|
389
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
|
+
}
|
|
390
466
|
const can = [];
|
|
391
467
|
const changed = {};
|
|
392
468
|
let stuck = null;
|
|
393
469
|
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,
|
|
396
|
-
const counted = (result) => {
|
|
397
|
-
const outcome = whatHappened(result);
|
|
398
|
-
return outcome === 'untested' ? null : outcome === 'got through' ? result.count : 0;
|
|
399
|
-
};
|
|
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);
|
|
400
472
|
const was = counted(before);
|
|
401
473
|
const now = counted(after);
|
|
402
474
|
if (was === null || now === null) {
|
|
@@ -407,23 +479,65 @@ async function teammate(client, schema, tables, seeded) {
|
|
|
407
479
|
}
|
|
408
480
|
}
|
|
409
481
|
if (!can.length && stuck) {
|
|
410
|
-
blocked.push({ table: table.name, key: key, why: as + ': ' + stuck });
|
|
411
|
-
|
|
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
|
+
}
|
|
412
499
|
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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);
|
|
427
541
|
}
|
|
428
542
|
}
|
|
429
543
|
}
|
|
@@ -438,6 +552,8 @@ module.exports = {
|
|
|
438
552
|
writeAsFor: writeAsFor,
|
|
439
553
|
tamper: tamper,
|
|
440
554
|
lowestRole: lowestRole,
|
|
555
|
+
secretColumns: secretColumns,
|
|
556
|
+
lookOnly: lookOnly,
|
|
441
557
|
roleColumnOf: roleColumnOf,
|
|
442
558
|
memberships: memberships,
|
|
443
559
|
teammate: teammate,
|