kryptheon-night 0.1.30 → 0.1.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/attack.js +57 -1
- package/engine.sql +286 -9
- package/finding.js +105 -11
- package/installer.js +232 -0
- package/intro.js +30 -6
- package/package.json +6 -3
- package/scan.js +17 -3
- package/schema.js +54 -12
- package/tamper.js +30 -5
package/README.md
CHANGED
|
@@ -167,6 +167,14 @@ properly.
|
|
|
167
167
|
`uninstall` removes the job, the schema, and any extension it had to add, and
|
|
168
168
|
leaves alone anything that was already there.
|
|
169
169
|
|
|
170
|
+
**No terminal at all?** Your dashboard on kryptheon.tech gives you the same
|
|
171
|
+
install as one SQL script: paste it into your Supabase SQL editor and press
|
|
172
|
+
Run. It leaves your database exactly as `npx kryptheon-night install` does —
|
|
173
|
+
`installscript.check.js` installs both ways side by side and compares every
|
|
174
|
+
function, the job, the token and the record uninstall reads — and the matching
|
|
175
|
+
uninstall script takes it back out, whichever way it went in. From code:
|
|
176
|
+
`require('kryptheon-night/installer.js').installScript({ source, reportTo })`.
|
|
177
|
+
|
|
170
178
|
## Three answers, three exit codes
|
|
171
179
|
|
|
172
180
|
| exit | meaning |
|
package/attack.js
CHANGED
|
@@ -531,6 +531,27 @@ function insertRow(client, schema, table, row, back) {
|
|
|
531
531
|
);
|
|
532
532
|
}
|
|
533
533
|
|
|
534
|
+
/**
|
|
535
|
+
* The column that says which organization's a row is, on a table with no
|
|
536
|
+
* person's name on it - or null. Its first key to a table that others point
|
|
537
|
+
* at (an organization, a workspace, a team), where the table itself is not one
|
|
538
|
+
* of those: seeding gives each person their own organization, and a table like
|
|
539
|
+
* this one row, in the first person's. Found on a blind test (HarborLine):
|
|
540
|
+
* integration_secrets(organization_id, access_token) was readable by every
|
|
541
|
+
* signed-in user, and with no person column it was never tried at all.
|
|
542
|
+
*/
|
|
543
|
+
function tenantKey(table, tables, parents) {
|
|
544
|
+
// A view is not seeded: it shows whatever its tables hold, the second
|
|
545
|
+
// person's own organization included, so a row of it read by them proves
|
|
546
|
+
// nothing. Found on the same test: workspace_health, filtered to the
|
|
547
|
+
// organizations the reader belongs to, came back as a leak.
|
|
548
|
+
if (table.isView || parents.has(table.name)) return null;
|
|
549
|
+
for (const key of foreignKeys(table).concat(impliedKeys(table, tables))) {
|
|
550
|
+
if (key.refTable !== table.name && parents.has(key.refTable) && key.columns.length === 1) return key.columns[0];
|
|
551
|
+
}
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
|
|
534
555
|
/** Every table some other table points at, by a key it declared or one it implies. */
|
|
535
556
|
function parentTables(tables) {
|
|
536
557
|
const parents = new Set();
|
|
@@ -619,7 +640,19 @@ async function seed(client, schema, tables) {
|
|
|
619
640
|
// this table can use the same one instead of rediscovering it, and be
|
|
620
641
|
// sure a refusal is the app defending itself rather than a CHECK it
|
|
621
642
|
// never satisfied.
|
|
622
|
-
|
|
643
|
+
const entry = { table: table.name, owner: owner, attempt: worked };
|
|
644
|
+
// A table with nobody's name on it that got a row for each person - an
|
|
645
|
+
// organization's invoices, say - holds the second person's own row too.
|
|
646
|
+
// Which row is the first person's is kept, so a write meant for
|
|
647
|
+
// "somebody else's row" is aimed at it. Found on a blind test
|
|
648
|
+
// (HarborLine): an unscoped UPDATE changed the second person's own
|
|
649
|
+
// invoice, and was reported as any customer changing another's.
|
|
650
|
+
const firstRow = !owner && rows.get(table.name) && rows.get(table.name).get(USER_A);
|
|
651
|
+
const pk = primaryKeyOf(table);
|
|
652
|
+
if (firstRow && rows.get(table.name).size === 2 && pk && firstRow[pk] !== null && firstRow[pk] !== undefined) {
|
|
653
|
+
entry.rowOfA = { column: pk, value: String(firstRow[pk]) };
|
|
654
|
+
}
|
|
655
|
+
seeded.push(entry);
|
|
623
656
|
} else {
|
|
624
657
|
// Recorded, never swallowed. The report has to say this table was not
|
|
625
658
|
// checked rather than let an empty table pass for a safe one.
|
|
@@ -747,8 +780,10 @@ async function impersonate(client, schema, tables) {
|
|
|
747
780
|
return false;
|
|
748
781
|
};
|
|
749
782
|
|
|
783
|
+
const parents = parentTables(tables);
|
|
750
784
|
for (const table of tables) {
|
|
751
785
|
const owner = ownerColumn(table);
|
|
786
|
+
const tenant = owner ? null : tenantKey(table, tables, parents);
|
|
752
787
|
const anon = await readAs(client, schema, table.name, 'anon', null);
|
|
753
788
|
const asA = await readAs(client, schema, table.name, 'authenticated', USER_A);
|
|
754
789
|
|
|
@@ -794,6 +829,27 @@ async function impersonate(client, schema, tables) {
|
|
|
794
829
|
});
|
|
795
830
|
}
|
|
796
831
|
}
|
|
832
|
+
|
|
833
|
+
// A table whose rows are an organization's, not a person's: its one row is
|
|
834
|
+
// the first person's organization's, and the second person - signed in,
|
|
835
|
+
// in an organization of their own - asks for it. What a logged-out
|
|
836
|
+
// stranger can already read is reported as exposed, and not twice.
|
|
837
|
+
if (tenant) {
|
|
838
|
+
const asB = await readAs(client, schema, table.name, 'authenticated', USER_B);
|
|
839
|
+
const open = Array.isArray(anon) && anon.length > 0;
|
|
840
|
+
if (settle('crossed:' + table.name, table.name, asB, 'as a signed-in user in another organization') &&
|
|
841
|
+
asB.length > 0 && !open) {
|
|
842
|
+
findings.push({
|
|
843
|
+
kind: 'crossed',
|
|
844
|
+
table: table.name,
|
|
845
|
+
owner: tenant,
|
|
846
|
+
tenant: true,
|
|
847
|
+
readable: asB.length,
|
|
848
|
+
columns: Object.keys(asB[0] || {}),
|
|
849
|
+
rlsEnabled: table.rlsEnabled,
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
}
|
|
797
853
|
}
|
|
798
854
|
|
|
799
855
|
return { findings: findings, completed: completed, blocked: blocked, looped: looped };
|
package/engine.sql
CHANGED
|
@@ -367,6 +367,169 @@ EXCEPTION WHEN OTHERS THEN
|
|
|
367
367
|
RETURN '[]'::jsonb;
|
|
368
368
|
END $$;
|
|
369
369
|
|
|
370
|
+
/*
|
|
371
|
+
* Whether a function turns the caller away before it does anything, and
|
|
372
|
+
* whether its only check is one somebody with no role slips past (schema.js
|
|
373
|
+
* doorOf, read the same way: IF <check> THEN RAISE, before the first write
|
|
374
|
+
* and the first RETURN). `helpers` are { name, nullable } - a yes/no about
|
|
375
|
+
* the caller, or a role helper that answers NULL for a stranger.
|
|
376
|
+
*/
|
|
377
|
+
CREATE OR REPLACE FUNCTION __KN__.door_of(body text, helpers jsonb)
|
|
378
|
+
RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
|
|
379
|
+
DECLARE
|
|
380
|
+
text_ text := coalesce(body, '');
|
|
381
|
+
writes_re constant text := '\y(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\y';
|
|
382
|
+
guard_re constant text :=
|
|
383
|
+
'((?<!\yend\s)(?<!\yend\s\s)\yif\y((?:(?!\ythen\y).)*?)\ythen\s+raise\y(?!\s+(?:notice|warning|info|log|debug)\y))';
|
|
384
|
+
first_act integer := 2147483647;
|
|
385
|
+
m text[];
|
|
386
|
+
pos integer;
|
|
387
|
+
from_ integer := 1;
|
|
388
|
+
cond text;
|
|
389
|
+
refuses_on_no boolean;
|
|
390
|
+
refuses boolean;
|
|
391
|
+
null_safe boolean;
|
|
392
|
+
h jsonb;
|
|
393
|
+
asks boolean;
|
|
394
|
+
slips boolean := false;
|
|
395
|
+
BEGIN
|
|
396
|
+
IF text_ ~* writes_re THEN
|
|
397
|
+
first_act := least(first_act, length((regexp_split_to_array(text_, writes_re, 'i'))[1]) + 1);
|
|
398
|
+
END IF;
|
|
399
|
+
IF text_ ~* '\yreturn\y' THEN
|
|
400
|
+
first_act := least(first_act, length((regexp_split_to_array(text_, '\yreturn\y', 'i'))[1]) + 1);
|
|
401
|
+
END IF;
|
|
402
|
+
FOR m IN SELECT regexp_matches(text_, guard_re, 'gi') LOOP
|
|
403
|
+
pos := from_ + strpos(substr(text_, from_), m[1]) - 1;
|
|
404
|
+
from_ := pos + length(m[1]);
|
|
405
|
+
EXIT WHEN pos > first_act;
|
|
406
|
+
cond := btrim(m[2]);
|
|
407
|
+
-- Refusing when the check fails, not when it passes.
|
|
408
|
+
refuses_on_no := cond ~* '^not\y' OR cond ~* '\yis\s+(not\s+true|false)\s*$' OR cond ~* '=\s*false\s*$';
|
|
409
|
+
FOR h IN SELECT * FROM jsonb_array_elements(coalesce(helpers, '[]'::jsonb)) LOOP
|
|
410
|
+
asks := cond ~* ('(^|[^\w$"])"?' || replace(h->>'name', '$', '\$') || '"?\s*\(');
|
|
411
|
+
CONTINUE WHEN NOT asks;
|
|
412
|
+
IF NOT coalesce((h->>'nullable')::boolean, false) THEN
|
|
413
|
+
IF refuses_on_no THEN RETURN jsonb_build_object('guarded', true, 'nullSlips', false); END IF;
|
|
414
|
+
ELSE
|
|
415
|
+
refuses := refuses_on_no OR cond ~* '\ynot\s+in\s*\(' OR cond ~ '<>|!=';
|
|
416
|
+
null_safe := cond ~* '\ycoalesce\s*\(' OR cond ~* '\yis\s+null\y' OR cond ~* '\yis\s+not\s+true\s*$';
|
|
417
|
+
IF refuses AND null_safe THEN RETURN jsonb_build_object('guarded', true, 'nullSlips', false); END IF;
|
|
418
|
+
IF refuses THEN slips := true; END IF;
|
|
419
|
+
END IF;
|
|
420
|
+
END LOOP;
|
|
421
|
+
IF refuses_on_no AND cond ~* '\yauth\s*\.\s*(uid|jwt)\s*\(' AND cond ~* 'admin|owner|member|role' THEN
|
|
422
|
+
RETURN jsonb_build_object('guarded', true, 'nullSlips', false);
|
|
423
|
+
END IF;
|
|
424
|
+
END LOOP;
|
|
425
|
+
RETURN jsonb_build_object('guarded', false, 'nullSlips', slips);
|
|
426
|
+
END $$;
|
|
427
|
+
|
|
428
|
+
/*
|
|
429
|
+
* Every SECURITY DEFINER function in the schema, trigger functions aside, and
|
|
430
|
+
* what the report needs to know about each (schema.js
|
|
431
|
+
* readAnonDefinerFunctions). Found on a blind test (HarborLine): the nightly
|
|
432
|
+
* run never looked at functions at all, so mark_invoice_paid, set_team_role
|
|
433
|
+
* and every other open one showed on the command line and never on the
|
|
434
|
+
* dashboard.
|
|
435
|
+
*/
|
|
436
|
+
CREATE OR REPLACE FUNCTION __KN__.read_anon_definer_functions(source text, role_ text DEFAULT 'anon')
|
|
437
|
+
RETURNS jsonb LANGUAGE plpgsql STABLE AS $$
|
|
438
|
+
DECLARE
|
|
439
|
+
writes_re constant text := '\y(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\y';
|
|
440
|
+
auth_re constant text := '\yauth\s*\.\s*(uid|jwt)\s*\(';
|
|
441
|
+
has_role boolean := EXISTS (SELECT 1 FROM pg_roles WHERE rolname = role_);
|
|
442
|
+
helpers jsonb;
|
|
443
|
+
out_ jsonb := '[]'::jsonb;
|
|
444
|
+
r record;
|
|
445
|
+
body text;
|
|
446
|
+
conf text;
|
|
447
|
+
writes boolean;
|
|
448
|
+
door jsonb;
|
|
449
|
+
BEGIN
|
|
450
|
+
SELECT coalesce(jsonb_agg(jsonb_build_object('name', h.name, 'nullable', h.nullable) ORDER BY h.name), '[]'::jsonb)
|
|
451
|
+
INTO helpers
|
|
452
|
+
FROM (SELECT p.proname::text AS name, p.prorettype <> 'pg_catalog.bool'::regtype AS nullable,
|
|
453
|
+
regexp_replace(coalesce(pg_get_functiondef(p.oid), ''), '--[^\n]*', ' ', 'g') AS b
|
|
454
|
+
FROM pg_proc p
|
|
455
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
456
|
+
JOIN pg_type t ON t.oid = p.prorettype
|
|
457
|
+
WHERE n.nspname = source AND p.prokind = 'f'
|
|
458
|
+
AND (p.prorettype = 'pg_catalog.bool'::regtype OR t.typtype = 'e')) h
|
|
459
|
+
WHERE h.b ~* auth_re AND NOT h.b ~* writes_re;
|
|
460
|
+
|
|
461
|
+
FOR r IN
|
|
462
|
+
SELECT p.proname::text AS name,
|
|
463
|
+
pg_get_function_identity_arguments(p.oid) AS args,
|
|
464
|
+
p.proconfig::text AS config,
|
|
465
|
+
pg_get_functiondef(p.oid) AS def,
|
|
466
|
+
p.prorettype = 'pg_catalog.bool'::regtype AS returns_bool,
|
|
467
|
+
(SELECT t.typtype = 'e' FROM pg_type t WHERE t.oid = p.prorettype) AS returns_enum,
|
|
468
|
+
CASE WHEN has_role THEN has_function_privilege(role_, p.oid, 'EXECUTE') ELSE false END AS callable
|
|
469
|
+
FROM pg_proc p
|
|
470
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
471
|
+
WHERE n.nspname = source
|
|
472
|
+
AND p.prosecdef
|
|
473
|
+
AND p.prorettype <> 'pg_catalog.trigger'::regtype
|
|
474
|
+
ORDER BY p.proname
|
|
475
|
+
LOOP
|
|
476
|
+
body := regexp_replace(coalesce(r.def, ''), '--[^\n]*', ' ', 'g');
|
|
477
|
+
conf := replace(replace(replace(coalesce(r.config, ''), '{', ''), '}', ''), '"', '');
|
|
478
|
+
writes := body ~* writes_re;
|
|
479
|
+
door := __KN__.door_of(body, helpers);
|
|
480
|
+
out_ := out_ || jsonb_build_array(jsonb_build_object(
|
|
481
|
+
'name', r.name,
|
|
482
|
+
'args', coalesce(r.args, ''),
|
|
483
|
+
'writes', writes,
|
|
484
|
+
'aboutCaller', (coalesce(r.returns_bool, false) OR coalesce(r.returns_enum, false)) AND NOT writes AND body ~* auth_re,
|
|
485
|
+
'guarded', (door->>'guarded')::boolean,
|
|
486
|
+
'nullSlips', (door->>'nullSlips')::boolean,
|
|
487
|
+
'hasFixedSearchPath', conf ~* '(^|,)search_path=',
|
|
488
|
+
'callable', coalesce(r.callable, false)));
|
|
489
|
+
END LOOP;
|
|
490
|
+
RETURN out_;
|
|
491
|
+
END $$;
|
|
492
|
+
|
|
493
|
+
/*
|
|
494
|
+
* Public buckets worth a look (scan.js bucketsOf): a read rule says only some
|
|
495
|
+
* people may read it, or there is no read rule and its name says its files
|
|
496
|
+
* are somebody's own.
|
|
497
|
+
*/
|
|
498
|
+
CREATE OR REPLACE FUNCTION __KN__.bucket_findings(buckets jsonb)
|
|
499
|
+
RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
|
|
500
|
+
DECLARE
|
|
501
|
+
b jsonb;
|
|
502
|
+
rule jsonb;
|
|
503
|
+
esc text;
|
|
504
|
+
rest text;
|
|
505
|
+
narrowing jsonb;
|
|
506
|
+
no_rule boolean;
|
|
507
|
+
found jsonb := '[]'::jsonb;
|
|
508
|
+
BEGIN
|
|
509
|
+
FOR b IN SELECT * FROM jsonb_array_elements(coalesce(buckets, '[]'::jsonb)) LOOP
|
|
510
|
+
CONTINUE WHEN NOT coalesce((b->>'public')::boolean, false);
|
|
511
|
+
esc := regexp_replace(b->>'id', '([.*+?^${}()|\[\]\\])', '\\\1', 'g');
|
|
512
|
+
narrowing := '[]'::jsonb;
|
|
513
|
+
FOR rule IN SELECT * FROM jsonb_array_elements(coalesce(b->'readRules', '[]'::jsonb)) LOOP
|
|
514
|
+
rest := regexp_replace(coalesce(rule->>'qual', ''), 'bucket_id\s*=\s*''' || esc || '''(::text)?', '', 'gi');
|
|
515
|
+
rest := regexp_replace(rest, '''' || esc || '''(::text)?\s*=\s*bucket_id', '', 'gi');
|
|
516
|
+
rest := regexp_replace(rest, '\ytrue\y|\yand\y|[()\s]', '', 'gi');
|
|
517
|
+
IF NOT (coalesce(rule->'roles', '[]'::jsonb) ? 'anon' OR coalesce(rule->'roles', '[]'::jsonb) ? 'public')
|
|
518
|
+
OR rest <> '' THEN
|
|
519
|
+
narrowing := narrowing || to_jsonb(rule->>'name');
|
|
520
|
+
END IF;
|
|
521
|
+
END LOOP;
|
|
522
|
+
no_rule := jsonb_array_length(coalesce(b->'readRules', '[]'::jsonb)) = 0
|
|
523
|
+
AND (b->>'name') ~* '(^|[-_.])(exports?|private|documents?|docs|invoices?|contracts?|backups?|reports?|receipts?|statements?|payouts?|evidence|kyc|passports?|medical|records?|confidential|internal|secrets?|attachments?)([-_.]|$)';
|
|
524
|
+
IF jsonb_array_length(narrowing) > 0 OR no_rule THEN
|
|
525
|
+
found := found || jsonb_build_array(jsonb_build_object(
|
|
526
|
+
'kind', 'bucket', 'table', b->>'name', 'bucket', b->>'id',
|
|
527
|
+
'rules', narrowing, 'noRule', no_rule, 'columns', '[]'::jsonb));
|
|
528
|
+
END IF;
|
|
529
|
+
END LOOP;
|
|
530
|
+
RETURN found;
|
|
531
|
+
END $$;
|
|
532
|
+
|
|
370
533
|
/* Everything needed to rebuild a schema, in one shape. */
|
|
371
534
|
CREATE OR REPLACE FUNCTION __KN__.read_schema(source text)
|
|
372
535
|
RETURNS jsonb LANGUAGE plpgsql STABLE AS $$
|
|
@@ -395,6 +558,7 @@ BEGIN
|
|
|
395
558
|
'viewGrants', __KN__.read_view_grants(source),
|
|
396
559
|
'functions', __KN__.read_functions(source),
|
|
397
560
|
'buckets', __KN__.read_buckets(),
|
|
561
|
+
'anonFunctions', __KN__.read_anon_definer_functions(source),
|
|
398
562
|
-- A foreign key pointing at something whose shape could not be read is
|
|
399
563
|
-- not something to guess at. The caller stops rather than attacking a
|
|
400
564
|
-- copy that is missing a piece.
|
|
@@ -1454,6 +1618,31 @@ BEGIN
|
|
|
1454
1618
|
RETURN keys;
|
|
1455
1619
|
END $$;
|
|
1456
1620
|
|
|
1621
|
+
/*
|
|
1622
|
+
* The column that says which organization's a row is, on a table with no
|
|
1623
|
+
* person's name on it - or NULL. Its first single-column key to a table that
|
|
1624
|
+
* others point at, where the table itself is not one of those (attack.js
|
|
1625
|
+
* tenantKey). Found on a blind test (HarborLine): integration_secrets was
|
|
1626
|
+
* readable by every signed-in user, and with no person column it was never
|
|
1627
|
+
* tried.
|
|
1628
|
+
*/
|
|
1629
|
+
CREATE OR REPLACE FUNCTION __KN__.tenant_key(tab jsonb, tables jsonb, parents jsonb)
|
|
1630
|
+
RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
|
|
1631
|
+
DECLARE
|
|
1632
|
+
key jsonb;
|
|
1633
|
+
BEGIN
|
|
1634
|
+
-- A view is not seeded: it shows whatever its tables hold, the second
|
|
1635
|
+
-- person's own organization included (workspace_health, on the same test).
|
|
1636
|
+
IF coalesce((tab->>'isView')::boolean, false) OR parents ? (tab->>'name') THEN RETURN NULL; END IF;
|
|
1637
|
+
FOR key IN SELECT * FROM jsonb_array_elements(__KN__.foreign_keys(tab) || __KN__.implied_keys(tab, tables)) LOOP
|
|
1638
|
+
IF key->>'refTable' <> tab->>'name' AND parents ? (key->>'refTable')
|
|
1639
|
+
AND jsonb_array_length(key->'columns') = 1 THEN
|
|
1640
|
+
RETURN key->'columns'->>0;
|
|
1641
|
+
END IF;
|
|
1642
|
+
END LOOP;
|
|
1643
|
+
RETURN NULL;
|
|
1644
|
+
END $$;
|
|
1645
|
+
|
|
1457
1646
|
/* Every table some other table points at, by a key it declared or one it implies. */
|
|
1458
1647
|
CREATE OR REPLACE FUNCTION __KN__.parent_tables(tables jsonb)
|
|
1459
1648
|
RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
|
|
@@ -1942,8 +2131,19 @@ BEGIN
|
|
|
1942
2131
|
-- into this table can use the same one instead of rediscovering it, and
|
|
1943
2132
|
-- be sure a refusal is the app defending itself rather than a CHECK it
|
|
1944
2133
|
-- never satisfied.
|
|
2134
|
+
-- A table with nobody's name on it that got a row for each person keeps
|
|
2135
|
+
-- which row is the first person's, so a write meant for somebody else's
|
|
2136
|
+
-- row is aimed at it (attack.js seed; HarborLine's invoices).
|
|
1945
2137
|
seeded := seeded || jsonb_build_array(jsonb_build_object(
|
|
1946
|
-
'table', tab->>'name', 'owner', owner, 'attempt', worked)
|
|
2138
|
+
'table', tab->>'name', 'owner', owner, 'attempt', worked)
|
|
2139
|
+
|| CASE WHEN owner IS NULL
|
|
2140
|
+
AND (SELECT count(*) FROM jsonb_object_keys(rows_->(tab->>'name'))) = 2
|
|
2141
|
+
AND __KN__.primary_key_of(tab) IS NOT NULL
|
|
2142
|
+
AND rows_->(tab->>'name')->__KN__.user_a()->>__KN__.primary_key_of(tab) IS NOT NULL
|
|
2143
|
+
THEN jsonb_build_object('rowOfA', jsonb_build_object(
|
|
2144
|
+
'column', __KN__.primary_key_of(tab),
|
|
2145
|
+
'value', rows_->(tab->>'name')->__KN__.user_a()->>__KN__.primary_key_of(tab)))
|
|
2146
|
+
ELSE '{}'::jsonb END);
|
|
1947
2147
|
ELSE
|
|
1948
2148
|
-- Recorded, never swallowed. The report has to say this table was not
|
|
1949
2149
|
-- checked rather than let an empty table pass for a safe one.
|
|
@@ -2093,9 +2293,13 @@ DECLARE
|
|
|
2093
2293
|
key text;
|
|
2094
2294
|
named jsonb;
|
|
2095
2295
|
reached boolean;
|
|
2296
|
+
parents jsonb := __KN__.parent_tables(tables);
|
|
2297
|
+
tenant text;
|
|
2298
|
+
as_b jsonb;
|
|
2096
2299
|
BEGIN
|
|
2097
2300
|
FOR tab IN SELECT * FROM jsonb_array_elements(tables) LOOP
|
|
2098
2301
|
owner := __KN__.owner_column(tab);
|
|
2302
|
+
tenant := CASE WHEN owner IS NULL THEN __KN__.tenant_key(tab, tables, parents) END;
|
|
2099
2303
|
named := coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c),
|
|
2100
2304
|
'[]'::jsonb);
|
|
2101
2305
|
|
|
@@ -2170,6 +2374,35 @@ BEGIN
|
|
|
2170
2374
|
'why', 'as a signed-in customer: ' || (as_a->>'why')));
|
|
2171
2375
|
END IF;
|
|
2172
2376
|
END IF;
|
|
2377
|
+
|
|
2378
|
+
-- A table whose rows are an organization's, not a person's: its one row
|
|
2379
|
+
-- is the first person's organization's, and the second person - signed
|
|
2380
|
+
-- in, in an organization of their own - asks for it. What a logged-out
|
|
2381
|
+
-- stranger can already read is reported as exposed, and not twice.
|
|
2382
|
+
IF tenant IS NOT NULL THEN
|
|
2383
|
+
key := 'crossed:' || (tab->>'name');
|
|
2384
|
+
as_b := __KN__.read_as(source, tab->>'name', 'authenticated', __KN__.user_b(), NULL);
|
|
2385
|
+
IF (as_b->>'ok')::boolean THEN
|
|
2386
|
+
completed := completed || to_jsonb(key);
|
|
2387
|
+
IF (as_b->>'count')::bigint > 0
|
|
2388
|
+
AND NOT ((anon->>'ok')::boolean AND (anon->>'count')::bigint > 0) THEN
|
|
2389
|
+
findings := findings || jsonb_build_array(jsonb_build_object(
|
|
2390
|
+
'kind', 'crossed',
|
|
2391
|
+
'table', tab->>'name',
|
|
2392
|
+
'owner', tenant,
|
|
2393
|
+
'tenant', true,
|
|
2394
|
+
'readable', (as_b->>'count')::bigint,
|
|
2395
|
+
'columns', named,
|
|
2396
|
+
'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false)));
|
|
2397
|
+
END IF;
|
|
2398
|
+
ELSIF __KN__.refusal_means(as_b->>'why') = 'unreachable' THEN
|
|
2399
|
+
completed := completed || to_jsonb(key);
|
|
2400
|
+
ELSE
|
|
2401
|
+
blocked := blocked || jsonb_build_array(jsonb_build_object(
|
|
2402
|
+
'table', tab->>'name', 'key', key,
|
|
2403
|
+
'why', 'as a signed-in user in another organization: ' || (as_b->>'why')));
|
|
2404
|
+
END IF;
|
|
2405
|
+
END IF;
|
|
2173
2406
|
END LOOP;
|
|
2174
2407
|
|
|
2175
2408
|
RETURN jsonb_build_object('findings', findings, 'completed', completed, 'blocked', blocked,
|
|
@@ -2340,10 +2573,11 @@ DECLARE
|
|
|
2340
2573
|
-- narrow what is allowed and never open it, so it is never the reason.
|
|
2341
2574
|
rules_for jsonb;
|
|
2342
2575
|
named jsonb;
|
|
2576
|
+
first_row jsonb;
|
|
2343
2577
|
BEGIN
|
|
2344
2578
|
FOR tab IN SELECT * FROM jsonb_array_elements(tables) LOOP
|
|
2345
2579
|
-- Never seeded, so there is nothing in it to protect.
|
|
2346
|
-
SELECT (s->>'attempt')::integer INTO shape
|
|
2580
|
+
SELECT (s->>'attempt')::integer, s->'rowOfA' INTO shape, first_row
|
|
2347
2581
|
FROM jsonb_array_elements(coalesce(seeded, '[]'::jsonb)) s
|
|
2348
2582
|
WHERE s->>'table' = tab->>'name';
|
|
2349
2583
|
CONTINUE WHEN NOT FOUND;
|
|
@@ -2369,8 +2603,14 @@ BEGIN
|
|
|
2369
2603
|
-- Rows belonging to the other fake person. Scoped on purpose: a signed-in
|
|
2370
2604
|
-- customer deleting their OWN rows is not a finding, it is the feature,
|
|
2371
2605
|
-- and an unscoped DELETE would report every correctly built app.
|
|
2372
|
-
|
|
2373
|
-
|
|
2606
|
+
-- With nobody's name on the table, the first person's own row where each
|
|
2607
|
+
-- person got one (seed's rowOfA).
|
|
2608
|
+
their_rows := CASE
|
|
2609
|
+
WHEN owner IS NOT NULL
|
|
2610
|
+
THEN ' WHERE ' || __KN__.always_quote(owner) || ' = ' || quote_literal(__KN__.user_a())
|
|
2611
|
+
WHEN jsonb_typeof(first_row) = 'object'
|
|
2612
|
+
THEN ' WHERE ' || __KN__.always_quote(first_row->>'column') || '::text = ' || quote_literal(first_row->>'value')
|
|
2613
|
+
ELSE '' END;
|
|
2374
2614
|
|
|
2375
2615
|
-- Written under a name nobody has used, which is both what makes the row
|
|
2376
2616
|
-- land at all and what makes it the right test: adding a row of your own
|
|
@@ -2641,7 +2881,8 @@ DECLARE
|
|
|
2641
2881
|
in_rule_literal text := '''\{([^}'']*,)?"?' || regexp_replace(role_, esc, '\\\1', 'g') ||
|
|
2642
2882
|
'"?(,[^}'']*)?\}''::("?[A-Za-z0-9_$]+"?\.)?"?' || type_name || '"?\[\]';
|
|
2643
2883
|
in_helper_list text := '(\mIN\s*\(|ARRAY\[)[^])]*' || label;
|
|
2644
|
-
|
|
2884
|
+
-- A takeover is an UPDATE too (tamper.js COMMAND).
|
|
2885
|
+
command text := CASE what WHEN 'add' THEN 'INSERT' WHEN 'change' THEN 'UPDATE' WHEN 'delete' THEN 'DELETE' WHEN 'takeover' THEN 'UPDATE' END;
|
|
2645
2886
|
policy jsonb;
|
|
2646
2887
|
body text;
|
|
2647
2888
|
BEGIN
|
|
@@ -2825,6 +3066,18 @@ BEGIN
|
|
|
2825
3066
|
jsonb_build_object('what', 'change',
|
|
2826
3067
|
'statement', 'UPDATE ' || target || ' SET ' || column_ || ' = ' || column_ || not_theirs),
|
|
2827
3068
|
jsonb_build_object('what', 'delete', 'statement', 'DELETE FROM ' || target || not_theirs));
|
|
3069
|
+
-- On the members table itself: putting their own id on the first
|
|
3070
|
+
-- person's membership row - taking that member's place, and their role
|
|
3071
|
+
-- (tamper.js; HarborLine). Asked of every role, listed or not.
|
|
3072
|
+
IF tab->>'name' = member->'table'->>'name' THEN
|
|
3073
|
+
where_ := ARRAY[__KN__.always_quote(member->>'person') || ' = ' || quote_literal(__KN__.user_a())];
|
|
3074
|
+
FOR name IN SELECT jsonb_object_keys(pointed) ORDER BY 1 LOOP
|
|
3075
|
+
where_ := where_ || (__KN__.always_quote(name) || ' = ' || quote_literal(pointed->>name));
|
|
3076
|
+
END LOOP;
|
|
3077
|
+
moves := moves || jsonb_build_array(jsonb_build_object('what', 'takeover', 'anyRole', true,
|
|
3078
|
+
'statement', 'UPDATE ' || target || ' SET ' || __KN__.always_quote(member->>'person') || ' = '
|
|
3079
|
+
|| quote_literal(__KN__.user_b()) || ' WHERE ' || array_to_string(where_, ' AND ')));
|
|
3080
|
+
END IF;
|
|
2828
3081
|
|
|
2829
3082
|
-- Adding a row to the team, said only of a role whose own name says it
|
|
2830
3083
|
-- may only look, and written under the other person's name: a rule that
|
|
@@ -2868,7 +3121,8 @@ BEGIN
|
|
|
2868
3121
|
CONTINUE WHEN coalesce((move->>'lookOnly')::boolean, false) AND NOT __KN__.look_only(role_);
|
|
2869
3122
|
-- A write the rules list this role for is not asked: whatever it did
|
|
2870
3123
|
-- would not be reported, and a key refusing it left the table untested.
|
|
2871
|
-
IF
|
|
3124
|
+
IF NOT coalesce((move->>'anyRole')::boolean, false)
|
|
3125
|
+
AND __KN__.allowed_by_rules(rules, tab->>'name', move->>'what', role_, member->>'roleType') THEN
|
|
2872
3126
|
listed := true;
|
|
2873
3127
|
CONTINUE;
|
|
2874
3128
|
END IF;
|
|
@@ -2903,7 +3157,10 @@ BEGIN
|
|
|
2903
3157
|
'changed', changed,
|
|
2904
3158
|
'below', below,
|
|
2905
3159
|
'columns', coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c), '[]'::jsonb),
|
|
2906
|
-
'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false))
|
|
3160
|
+
'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false))
|
|
3161
|
+
|| CASE WHEN can ? 'takeover'
|
|
3162
|
+
THEN jsonb_build_object('takeover', true, 'personColumn', member->>'person')
|
|
3163
|
+
ELSE '{}'::jsonb END);
|
|
2907
3164
|
EXIT;
|
|
2908
3165
|
END IF;
|
|
2909
3166
|
IF NOT listed THEN below := below || to_jsonb(role_); END IF;
|
|
@@ -3499,12 +3756,32 @@ BEGIN
|
|
|
3499
3756
|
FROM jsonb_array_elements(coalesce(seen->'looped', '[]'::jsonb)) l
|
|
3500
3757
|
GROUP BY l->>'relation') g), '[]'::jsonb);
|
|
3501
3758
|
|
|
3759
|
+
-- Functions anybody can call that run past the rules, and public buckets
|
|
3760
|
+
-- worth a look - read from the app's own shape, never run or opened
|
|
3761
|
+
-- (scan.js privilegedOf and bucketsOf). Found on a blind test
|
|
3762
|
+
-- (HarborLine): neither was ever in the nightly run, so the dashboard
|
|
3763
|
+
-- showed less than the command line did.
|
|
3764
|
+
findings := findings || coalesce((
|
|
3765
|
+
SELECT jsonb_agg(jsonb_build_object(
|
|
3766
|
+
'kind', 'privileged', 'fn', f->>'name', 'table', f->>'name', 'args', f->>'args',
|
|
3767
|
+
'writes', (f->>'writes')::boolean, 'hasFixedSearchPath', (f->>'hasFixedSearchPath')::boolean,
|
|
3768
|
+
'nullSlips', coalesce((f->>'nullSlips')::boolean, false), 'columns', '[]'::jsonb))
|
|
3769
|
+
FROM jsonb_array_elements(coalesce(plan->'anonFunctions', '[]'::jsonb)) f
|
|
3770
|
+
WHERE (f->>'callable')::boolean AND NOT (f->>'aboutCaller')::boolean AND NOT (f->>'guarded')::boolean),
|
|
3771
|
+
'[]'::jsonb)
|
|
3772
|
+
|| __KN__.bucket_findings(plan->'buckets');
|
|
3773
|
+
|
|
3502
3774
|
-- Everything genuinely attacked, so a re-check can tell a finding that was
|
|
3503
|
-
-- fixed from one whose attack simply did not run this time.
|
|
3775
|
+
-- fixed from one whose attack simply did not run this time. Every function
|
|
3776
|
+
-- and bucket read counts as looked at, so a revoked grant comes back fixed.
|
|
3504
3777
|
attempted := coalesce(seen->'completed', '[]'::jsonb)
|
|
3505
3778
|
|| coalesce(wrote->'completed', '[]'::jsonb)
|
|
3506
3779
|
|| coalesce(ranked->'completed', '[]'::jsonb)
|
|
3507
|
-
|| coalesce(stranded->'completed', '[]'::jsonb)
|
|
3780
|
+
|| coalesce(stranded->'completed', '[]'::jsonb)
|
|
3781
|
+
|| coalesce((SELECT jsonb_agg('privileged:' || (f->>'name'))
|
|
3782
|
+
FROM jsonb_array_elements(coalesce(plan->'anonFunctions', '[]'::jsonb)) f), '[]'::jsonb)
|
|
3783
|
+
|| coalesce((SELECT jsonb_agg('bucket:' || (b->>'name'))
|
|
3784
|
+
FROM jsonb_array_elements(coalesce(plan->'buckets', '[]'::jsonb)) b), '[]'::jsonb);
|
|
3508
3785
|
|
|
3509
3786
|
-- And everything that was not. Said out loud, never left out: a table
|
|
3510
3787
|
-- nothing could be put into reads exactly like a table nothing got out of.
|