kryptheon-night 0.1.31 → 0.1.33
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/attack.js +192 -4
- package/collision.js +6 -1
- package/engine.sql +440 -10
- package/finding.js +105 -11
- package/package.json +6 -3
- package/scan.js +17 -3
- package/schema.js +54 -12
- package/tamper.js +30 -5
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.
|
|
@@ -1300,6 +1464,155 @@ BEGIN
|
|
|
1300
1464
|
RETURN found;
|
|
1301
1465
|
END $$;
|
|
1302
1466
|
|
|
1467
|
+
/*
|
|
1468
|
+
* The CHECK constraints on a table that name this column, as the expression
|
|
1469
|
+
* inside CHECK (...). A NOT VALID one still applies to every new row.
|
|
1470
|
+
* Mirrors checksOn in attack.js.
|
|
1471
|
+
*/
|
|
1472
|
+
CREATE OR REPLACE FUNCTION __KN__.checks_on(tab jsonb, column_name text)
|
|
1473
|
+
RETURNS text[] LANGUAGE plpgsql IMMUTABLE AS $$
|
|
1474
|
+
DECLARE
|
|
1475
|
+
escaped text := regexp_replace(column_name, '([.*+?^${}()|\[\]\\])', '\\\1', 'g');
|
|
1476
|
+
named text;
|
|
1477
|
+
found text[] := ARRAY[]::text[];
|
|
1478
|
+
con jsonb;
|
|
1479
|
+
definition text;
|
|
1480
|
+
BEGIN
|
|
1481
|
+
named := '(^|[^A-Za-z0-9_$"])("' || replace(escaped, '"', '""') || '"|' || escaped || ')($|[^A-Za-z0-9_$"])';
|
|
1482
|
+
FOR con IN SELECT * FROM jsonb_array_elements(coalesce(tab->'constraints', '[]'::jsonb)) LOOP
|
|
1483
|
+
CONTINUE WHEN con->>'kind' <> 'c';
|
|
1484
|
+
definition := coalesce(con->>'definition', '');
|
|
1485
|
+
CONTINUE WHEN definition !~ named;
|
|
1486
|
+
found := found || regexp_replace(regexp_replace(definition, '^CHECK\s*', '', 'i'), '\s+NOT VALID\s*$', '', 'i');
|
|
1487
|
+
END LOOP;
|
|
1488
|
+
RETURN found;
|
|
1489
|
+
END $$;
|
|
1490
|
+
|
|
1491
|
+
/*
|
|
1492
|
+
* Values to offer a column whose CHECK could not be read, best first.
|
|
1493
|
+
*
|
|
1494
|
+
* Found on a real app (a clinic's, 2026-10-11): phone number columns with a
|
|
1495
|
+
* CHECK on their format, and a type column with a CHECK nothing here read -
|
|
1496
|
+
* three tables, and a fourth that pointed at one of them, never tested.
|
|
1497
|
+
* Nothing here reads the rule: each value is put to the rule itself
|
|
1498
|
+
* (fitting_values), and the first it accepts is used.
|
|
1499
|
+
*
|
|
1500
|
+
* Mirrors checkCandidates in attack.js, value for value and in the same
|
|
1501
|
+
* order: both engines must seed the same rows.
|
|
1502
|
+
*/
|
|
1503
|
+
CREATE OR REPLACE FUNCTION __KN__.check_candidates(col jsonb, definitions text[], distinct_ text)
|
|
1504
|
+
RETURNS text[] LANGUAGE plpgsql IMMUTABLE AS $$
|
|
1505
|
+
DECLARE
|
|
1506
|
+
kind text := lower(coalesce(col->>'base_type', col->>'type'));
|
|
1507
|
+
tag text := coalesce(distinct_, '');
|
|
1508
|
+
step integer := CASE WHEN coalesce(distinct_, '') ~ '^[0-9]+$' THEN distinct_::integer ELSE 0 END;
|
|
1509
|
+
d text;
|
|
1510
|
+
name_ text := lower(col->>'name');
|
|
1511
|
+
out_ text[] := ARRAY[]::text[];
|
|
1512
|
+
phones text[];
|
|
1513
|
+
emails text[];
|
|
1514
|
+
links text[];
|
|
1515
|
+
words text[];
|
|
1516
|
+
literals text[] := ARRAY[]::text[];
|
|
1517
|
+
codes text[];
|
|
1518
|
+
first_ text[] := ARRAY[]::text[];
|
|
1519
|
+
definition text;
|
|
1520
|
+
one text;
|
|
1521
|
+
value_ text;
|
|
1522
|
+
BEGIN
|
|
1523
|
+
IF kind ~ '^(integer|bigint|smallint|numeric|decimal|real|double)' THEN
|
|
1524
|
+
FOREACH value_ IN ARRAY ARRAY[(1 + step)::text, (18 + step)::text, (100 + step)::text, (1000 + step)::text, '0', '5'] LOOP
|
|
1525
|
+
IF NOT (value_ = ANY (out_)) THEN out_ := out_ || value_; END IF;
|
|
1526
|
+
END LOOP;
|
|
1527
|
+
FOREACH definition IN ARRAY coalesce(definitions, ARRAY[]::text[]) LOOP
|
|
1528
|
+
FOR one IN SELECT (m)[1] FROM regexp_matches(definition, '(-?\d+(?:\.\d+)?)', 'g') m LOOP
|
|
1529
|
+
IF NOT (one = ANY (out_)) THEN out_ := out_ || one; END IF;
|
|
1530
|
+
END LOOP;
|
|
1531
|
+
END LOOP;
|
|
1532
|
+
RETURN out_;
|
|
1533
|
+
END IF;
|
|
1534
|
+
IF kind !~ '^(text|character|varchar|citext)' THEN RETURN out_; END IF;
|
|
1535
|
+
d := (step % 10)::text;
|
|
1536
|
+
phones := ARRAY['+1415555012' || d, '+919876543' || (210 + step)::text, '9876543' || (210 + step)::text, '415555012' || d,
|
|
1537
|
+
'+1 415-555-012' || d, '(415) 555-012' || d, '415-555-012' || d, '+44 7700 90012' || d, '07700 90012' || d];
|
|
1538
|
+
emails := ARRAY['kryptheon' || tag || '@example.com'];
|
|
1539
|
+
links := ARRAY['https://example.com/kryptheon' || tag];
|
|
1540
|
+
-- The shapes value_for offers, in its order, so a rule they already passed
|
|
1541
|
+
-- still gets the value it always got.
|
|
1542
|
+
words := ARRAY[CASE WHEN tag <> '' THEN tag || ' kryptheon test' ELSE 'kryptheon test' END,
|
|
1543
|
+
'kryptheon' || tag || '@example.com', 'KN' || CASE WHEN tag <> '' THEN tag ELSE '1' END,
|
|
1544
|
+
(step + 1)::text, 'https://example.com/kryptheon', 'kryptheon' || tag, 'Kryptheon' || tag,
|
|
1545
|
+
'kn-' || CASE WHEN tag <> '' THEN tag ELSE '1' END];
|
|
1546
|
+
FOREACH definition IN ARRAY coalesce(definitions, ARRAY[]::text[]) LOOP
|
|
1547
|
+
FOR one IN SELECT (m)[1] FROM regexp_matches(definition, '''((?:[^'']|'''')*)''', 'g') m LOOP
|
|
1548
|
+
literals := literals || replace(one, '''''', '''');
|
|
1549
|
+
END LOOP;
|
|
1550
|
+
END LOOP;
|
|
1551
|
+
codes := ARRAY['US', 'IN', 'USD', 'INR', 'EUR', 'en', 'en-US', '#00000' || d, '2026-01-0' || (1 + (step % 9))::text,
|
|
1552
|
+
'1234' || d, '56000' || d,
|
|
1553
|
+
'kryptheon test value long enough for a rule on a minimum length ' || CASE WHEN tag <> '' THEN tag ELSE '0' END];
|
|
1554
|
+
IF name_ ~ 'phone|mobile|whatsapp|tel|msisdn' THEN first_ := phones;
|
|
1555
|
+
ELSIF name_ ~ 'mail' THEN first_ := emails;
|
|
1556
|
+
ELSIF name_ ~ 'url|link|website|site|href' THEN first_ := links;
|
|
1557
|
+
END IF;
|
|
1558
|
+
FOREACH value_ IN ARRAY first_ || words || literals || phones || emails || links || codes LOOP
|
|
1559
|
+
value_ := __KN__.fit_to(value_, kind);
|
|
1560
|
+
IF NOT (value_ = ANY (out_)) THEN out_ := out_ || value_; END IF;
|
|
1561
|
+
END LOOP;
|
|
1562
|
+
RETURN out_;
|
|
1563
|
+
END $$;
|
|
1564
|
+
|
|
1565
|
+
/*
|
|
1566
|
+
* The first values a column's CHECK constraints accept, asked of the
|
|
1567
|
+
* database itself: each rule is run on the value alone, the column given as
|
|
1568
|
+
* the value by a one-row subquery. A rule that also names another column
|
|
1569
|
+
* cannot be run alone and is left out; when no rule can be, nothing is
|
|
1570
|
+
* offered and the seeder goes on as before. At most five - as many as the
|
|
1571
|
+
* seeder ever tries. Mirrors fittingValues in attack.js.
|
|
1572
|
+
*/
|
|
1573
|
+
CREATE OR REPLACE FUNCTION __KN__.fitting_values(tab jsonb, col jsonb, distinct_ text)
|
|
1574
|
+
RETURNS text[] LANGUAGE plpgsql AS $$
|
|
1575
|
+
DECLARE
|
|
1576
|
+
name_ text := col->>'name';
|
|
1577
|
+
type_ text := col->>'type';
|
|
1578
|
+
definitions text[] := __KN__.checks_on(tab, col->>'name');
|
|
1579
|
+
candidates text[];
|
|
1580
|
+
runnable text[] := ARRAY[]::text[];
|
|
1581
|
+
all_ text;
|
|
1582
|
+
accepted text[] := ARRAY[]::text[];
|
|
1583
|
+
expression text;
|
|
1584
|
+
value_ text;
|
|
1585
|
+
ok boolean;
|
|
1586
|
+
BEGIN
|
|
1587
|
+
IF coalesce(array_length(definitions, 1), 0) = 0 THEN RETURN accepted; END IF;
|
|
1588
|
+
candidates := __KN__.check_candidates(col, definitions, distinct_);
|
|
1589
|
+
IF coalesce(array_length(candidates, 1), 0) = 0 THEN RETURN accepted; END IF;
|
|
1590
|
+
FOREACH expression IN ARRAY definitions LOOP
|
|
1591
|
+
BEGIN
|
|
1592
|
+
EXECUTE format('SELECT (%s) IS NOT FALSE AS ok FROM (SELECT NULL::%s AS %I) AS kn_row', expression, type_, name_);
|
|
1593
|
+
runnable := runnable || expression;
|
|
1594
|
+
EXCEPTION WHEN OTHERS THEN
|
|
1595
|
+
-- Names another column, or something only the table can supply.
|
|
1596
|
+
NULL;
|
|
1597
|
+
END;
|
|
1598
|
+
END LOOP;
|
|
1599
|
+
IF coalesce(array_length(runnable, 1), 0) = 0 THEN RETURN accepted; END IF;
|
|
1600
|
+
SELECT string_agg('(' || e || ') IS NOT FALSE', ' AND ') INTO all_ FROM unnest(runnable) e;
|
|
1601
|
+
FOREACH value_ IN ARRAY candidates LOOP
|
|
1602
|
+
BEGIN
|
|
1603
|
+
EXECUTE format('SELECT %s AS ok FROM (SELECT $1::%s AS %I) AS kn_row', all_, type_, name_) INTO ok USING value_;
|
|
1604
|
+
IF ok IS TRUE THEN
|
|
1605
|
+
accepted := accepted || value_;
|
|
1606
|
+
EXIT WHEN array_length(accepted, 1) >= 5;
|
|
1607
|
+
END IF;
|
|
1608
|
+
EXCEPTION WHEN OTHERS THEN
|
|
1609
|
+
-- A value its own type, or the rule, refuses outright.
|
|
1610
|
+
NULL;
|
|
1611
|
+
END;
|
|
1612
|
+
END LOOP;
|
|
1613
|
+
RETURN accepted;
|
|
1614
|
+
END $$;
|
|
1615
|
+
|
|
1303
1616
|
/*
|
|
1304
1617
|
* Something valid to put in a column, so a row can exist at all.
|
|
1305
1618
|
*
|
|
@@ -1454,6 +1767,31 @@ BEGIN
|
|
|
1454
1767
|
RETURN keys;
|
|
1455
1768
|
END $$;
|
|
1456
1769
|
|
|
1770
|
+
/*
|
|
1771
|
+
* The column that says which organization's a row is, on a table with no
|
|
1772
|
+
* person's name on it - or NULL. Its first single-column key to a table that
|
|
1773
|
+
* others point at, where the table itself is not one of those (attack.js
|
|
1774
|
+
* tenantKey). Found on a blind test (HarborLine): integration_secrets was
|
|
1775
|
+
* readable by every signed-in user, and with no person column it was never
|
|
1776
|
+
* tried.
|
|
1777
|
+
*/
|
|
1778
|
+
CREATE OR REPLACE FUNCTION __KN__.tenant_key(tab jsonb, tables jsonb, parents jsonb)
|
|
1779
|
+
RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
|
|
1780
|
+
DECLARE
|
|
1781
|
+
key jsonb;
|
|
1782
|
+
BEGIN
|
|
1783
|
+
-- A view is not seeded: it shows whatever its tables hold, the second
|
|
1784
|
+
-- person's own organization included (workspace_health, on the same test).
|
|
1785
|
+
IF coalesce((tab->>'isView')::boolean, false) OR parents ? (tab->>'name') THEN RETURN NULL; END IF;
|
|
1786
|
+
FOR key IN SELECT * FROM jsonb_array_elements(__KN__.foreign_keys(tab) || __KN__.implied_keys(tab, tables)) LOOP
|
|
1787
|
+
IF key->>'refTable' <> tab->>'name' AND parents ? (key->>'refTable')
|
|
1788
|
+
AND jsonb_array_length(key->'columns') = 1 THEN
|
|
1789
|
+
RETURN key->'columns'->>0;
|
|
1790
|
+
END IF;
|
|
1791
|
+
END LOOP;
|
|
1792
|
+
RETURN NULL;
|
|
1793
|
+
END $$;
|
|
1794
|
+
|
|
1457
1795
|
/* Every table some other table points at, by a key it declared or one it implies. */
|
|
1458
1796
|
CREATE OR REPLACE FUNCTION __KN__.parent_tables(tables jsonb)
|
|
1459
1797
|
RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
|
|
@@ -1794,8 +2132,12 @@ BEGIN
|
|
|
1794
2132
|
CONTINUE WHEN NOT (col->>'not_null')::boolean AND NOT must_fill;
|
|
1795
2133
|
|
|
1796
2134
|
columns := columns || to_jsonb(name);
|
|
1797
|
-
-- A CHECK that lists what it will accept beats anything invented here
|
|
2135
|
+
-- A CHECK that lists what it will accept beats anything invented here;
|
|
2136
|
+
-- after that, a value one it could not read was asked about and accepts.
|
|
1798
2137
|
allowed := __KN__.allowed_by_check(tab, name);
|
|
2138
|
+
IF coalesce(array_length(allowed, 1), 0) = 0 THEN
|
|
2139
|
+
allowed := __KN__.fitting_values(tab, col, distinct_);
|
|
2140
|
+
END IF;
|
|
1799
2141
|
IF array_length(allowed, 1) > 0 THEN
|
|
1800
2142
|
nth := least(greatest(coalesce(attempt, 0), 0), array_length(allowed, 1) - 1);
|
|
1801
2143
|
values_ := values_ || jsonb_build_array(to_jsonb(allowed[nth + 1]));
|
|
@@ -1942,8 +2284,19 @@ BEGIN
|
|
|
1942
2284
|
-- into this table can use the same one instead of rediscovering it, and
|
|
1943
2285
|
-- be sure a refusal is the app defending itself rather than a CHECK it
|
|
1944
2286
|
-- never satisfied.
|
|
2287
|
+
-- A table with nobody's name on it that got a row for each person keeps
|
|
2288
|
+
-- which row is the first person's, so a write meant for somebody else's
|
|
2289
|
+
-- row is aimed at it (attack.js seed; HarborLine's invoices).
|
|
1945
2290
|
seeded := seeded || jsonb_build_array(jsonb_build_object(
|
|
1946
|
-
'table', tab->>'name', 'owner', owner, 'attempt', worked)
|
|
2291
|
+
'table', tab->>'name', 'owner', owner, 'attempt', worked)
|
|
2292
|
+
|| CASE WHEN owner IS NULL
|
|
2293
|
+
AND (SELECT count(*) FROM jsonb_object_keys(rows_->(tab->>'name'))) = 2
|
|
2294
|
+
AND __KN__.primary_key_of(tab) IS NOT NULL
|
|
2295
|
+
AND rows_->(tab->>'name')->__KN__.user_a()->>__KN__.primary_key_of(tab) IS NOT NULL
|
|
2296
|
+
THEN jsonb_build_object('rowOfA', jsonb_build_object(
|
|
2297
|
+
'column', __KN__.primary_key_of(tab),
|
|
2298
|
+
'value', rows_->(tab->>'name')->__KN__.user_a()->>__KN__.primary_key_of(tab)))
|
|
2299
|
+
ELSE '{}'::jsonb END);
|
|
1947
2300
|
ELSE
|
|
1948
2301
|
-- Recorded, never swallowed. The report has to say this table was not
|
|
1949
2302
|
-- checked rather than let an empty table pass for a safe one.
|
|
@@ -2093,9 +2446,13 @@ DECLARE
|
|
|
2093
2446
|
key text;
|
|
2094
2447
|
named jsonb;
|
|
2095
2448
|
reached boolean;
|
|
2449
|
+
parents jsonb := __KN__.parent_tables(tables);
|
|
2450
|
+
tenant text;
|
|
2451
|
+
as_b jsonb;
|
|
2096
2452
|
BEGIN
|
|
2097
2453
|
FOR tab IN SELECT * FROM jsonb_array_elements(tables) LOOP
|
|
2098
2454
|
owner := __KN__.owner_column(tab);
|
|
2455
|
+
tenant := CASE WHEN owner IS NULL THEN __KN__.tenant_key(tab, tables, parents) END;
|
|
2099
2456
|
named := coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c),
|
|
2100
2457
|
'[]'::jsonb);
|
|
2101
2458
|
|
|
@@ -2170,6 +2527,35 @@ BEGIN
|
|
|
2170
2527
|
'why', 'as a signed-in customer: ' || (as_a->>'why')));
|
|
2171
2528
|
END IF;
|
|
2172
2529
|
END IF;
|
|
2530
|
+
|
|
2531
|
+
-- A table whose rows are an organization's, not a person's: its one row
|
|
2532
|
+
-- is the first person's organization's, and the second person - signed
|
|
2533
|
+
-- in, in an organization of their own - asks for it. What a logged-out
|
|
2534
|
+
-- stranger can already read is reported as exposed, and not twice.
|
|
2535
|
+
IF tenant IS NOT NULL THEN
|
|
2536
|
+
key := 'crossed:' || (tab->>'name');
|
|
2537
|
+
as_b := __KN__.read_as(source, tab->>'name', 'authenticated', __KN__.user_b(), NULL);
|
|
2538
|
+
IF (as_b->>'ok')::boolean THEN
|
|
2539
|
+
completed := completed || to_jsonb(key);
|
|
2540
|
+
IF (as_b->>'count')::bigint > 0
|
|
2541
|
+
AND NOT ((anon->>'ok')::boolean AND (anon->>'count')::bigint > 0) THEN
|
|
2542
|
+
findings := findings || jsonb_build_array(jsonb_build_object(
|
|
2543
|
+
'kind', 'crossed',
|
|
2544
|
+
'table', tab->>'name',
|
|
2545
|
+
'owner', tenant,
|
|
2546
|
+
'tenant', true,
|
|
2547
|
+
'readable', (as_b->>'count')::bigint,
|
|
2548
|
+
'columns', named,
|
|
2549
|
+
'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false)));
|
|
2550
|
+
END IF;
|
|
2551
|
+
ELSIF __KN__.refusal_means(as_b->>'why') = 'unreachable' THEN
|
|
2552
|
+
completed := completed || to_jsonb(key);
|
|
2553
|
+
ELSE
|
|
2554
|
+
blocked := blocked || jsonb_build_array(jsonb_build_object(
|
|
2555
|
+
'table', tab->>'name', 'key', key,
|
|
2556
|
+
'why', 'as a signed-in user in another organization: ' || (as_b->>'why')));
|
|
2557
|
+
END IF;
|
|
2558
|
+
END IF;
|
|
2173
2559
|
END LOOP;
|
|
2174
2560
|
|
|
2175
2561
|
RETURN jsonb_build_object('findings', findings, 'completed', completed, 'blocked', blocked,
|
|
@@ -2340,10 +2726,11 @@ DECLARE
|
|
|
2340
2726
|
-- narrow what is allowed and never open it, so it is never the reason.
|
|
2341
2727
|
rules_for jsonb;
|
|
2342
2728
|
named jsonb;
|
|
2729
|
+
first_row jsonb;
|
|
2343
2730
|
BEGIN
|
|
2344
2731
|
FOR tab IN SELECT * FROM jsonb_array_elements(tables) LOOP
|
|
2345
2732
|
-- Never seeded, so there is nothing in it to protect.
|
|
2346
|
-
SELECT (s->>'attempt')::integer INTO shape
|
|
2733
|
+
SELECT (s->>'attempt')::integer, s->'rowOfA' INTO shape, first_row
|
|
2347
2734
|
FROM jsonb_array_elements(coalesce(seeded, '[]'::jsonb)) s
|
|
2348
2735
|
WHERE s->>'table' = tab->>'name';
|
|
2349
2736
|
CONTINUE WHEN NOT FOUND;
|
|
@@ -2369,8 +2756,14 @@ BEGIN
|
|
|
2369
2756
|
-- Rows belonging to the other fake person. Scoped on purpose: a signed-in
|
|
2370
2757
|
-- customer deleting their OWN rows is not a finding, it is the feature,
|
|
2371
2758
|
-- and an unscoped DELETE would report every correctly built app.
|
|
2372
|
-
|
|
2373
|
-
|
|
2759
|
+
-- With nobody's name on the table, the first person's own row where each
|
|
2760
|
+
-- person got one (seed's rowOfA).
|
|
2761
|
+
their_rows := CASE
|
|
2762
|
+
WHEN owner IS NOT NULL
|
|
2763
|
+
THEN ' WHERE ' || __KN__.always_quote(owner) || ' = ' || quote_literal(__KN__.user_a())
|
|
2764
|
+
WHEN jsonb_typeof(first_row) = 'object'
|
|
2765
|
+
THEN ' WHERE ' || __KN__.always_quote(first_row->>'column') || '::text = ' || quote_literal(first_row->>'value')
|
|
2766
|
+
ELSE '' END;
|
|
2374
2767
|
|
|
2375
2768
|
-- Written under a name nobody has used, which is both what makes the row
|
|
2376
2769
|
-- land at all and what makes it the right test: adding a row of your own
|
|
@@ -2641,7 +3034,8 @@ DECLARE
|
|
|
2641
3034
|
in_rule_literal text := '''\{([^}'']*,)?"?' || regexp_replace(role_, esc, '\\\1', 'g') ||
|
|
2642
3035
|
'"?(,[^}'']*)?\}''::("?[A-Za-z0-9_$]+"?\.)?"?' || type_name || '"?\[\]';
|
|
2643
3036
|
in_helper_list text := '(\mIN\s*\(|ARRAY\[)[^])]*' || label;
|
|
2644
|
-
|
|
3037
|
+
-- A takeover is an UPDATE too (tamper.js COMMAND).
|
|
3038
|
+
command text := CASE what WHEN 'add' THEN 'INSERT' WHEN 'change' THEN 'UPDATE' WHEN 'delete' THEN 'DELETE' WHEN 'takeover' THEN 'UPDATE' END;
|
|
2645
3039
|
policy jsonb;
|
|
2646
3040
|
body text;
|
|
2647
3041
|
BEGIN
|
|
@@ -2825,6 +3219,18 @@ BEGIN
|
|
|
2825
3219
|
jsonb_build_object('what', 'change',
|
|
2826
3220
|
'statement', 'UPDATE ' || target || ' SET ' || column_ || ' = ' || column_ || not_theirs),
|
|
2827
3221
|
jsonb_build_object('what', 'delete', 'statement', 'DELETE FROM ' || target || not_theirs));
|
|
3222
|
+
-- On the members table itself: putting their own id on the first
|
|
3223
|
+
-- person's membership row - taking that member's place, and their role
|
|
3224
|
+
-- (tamper.js; HarborLine). Asked of every role, listed or not.
|
|
3225
|
+
IF tab->>'name' = member->'table'->>'name' THEN
|
|
3226
|
+
where_ := ARRAY[__KN__.always_quote(member->>'person') || ' = ' || quote_literal(__KN__.user_a())];
|
|
3227
|
+
FOR name IN SELECT jsonb_object_keys(pointed) ORDER BY 1 LOOP
|
|
3228
|
+
where_ := where_ || (__KN__.always_quote(name) || ' = ' || quote_literal(pointed->>name));
|
|
3229
|
+
END LOOP;
|
|
3230
|
+
moves := moves || jsonb_build_array(jsonb_build_object('what', 'takeover', 'anyRole', true,
|
|
3231
|
+
'statement', 'UPDATE ' || target || ' SET ' || __KN__.always_quote(member->>'person') || ' = '
|
|
3232
|
+
|| quote_literal(__KN__.user_b()) || ' WHERE ' || array_to_string(where_, ' AND ')));
|
|
3233
|
+
END IF;
|
|
2828
3234
|
|
|
2829
3235
|
-- Adding a row to the team, said only of a role whose own name says it
|
|
2830
3236
|
-- may only look, and written under the other person's name: a rule that
|
|
@@ -2868,7 +3274,8 @@ BEGIN
|
|
|
2868
3274
|
CONTINUE WHEN coalesce((move->>'lookOnly')::boolean, false) AND NOT __KN__.look_only(role_);
|
|
2869
3275
|
-- A write the rules list this role for is not asked: whatever it did
|
|
2870
3276
|
-- would not be reported, and a key refusing it left the table untested.
|
|
2871
|
-
IF
|
|
3277
|
+
IF NOT coalesce((move->>'anyRole')::boolean, false)
|
|
3278
|
+
AND __KN__.allowed_by_rules(rules, tab->>'name', move->>'what', role_, member->>'roleType') THEN
|
|
2872
3279
|
listed := true;
|
|
2873
3280
|
CONTINUE;
|
|
2874
3281
|
END IF;
|
|
@@ -2903,7 +3310,10 @@ BEGIN
|
|
|
2903
3310
|
'changed', changed,
|
|
2904
3311
|
'below', below,
|
|
2905
3312
|
'columns', coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c), '[]'::jsonb),
|
|
2906
|
-
'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false))
|
|
3313
|
+
'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false))
|
|
3314
|
+
|| CASE WHEN can ? 'takeover'
|
|
3315
|
+
THEN jsonb_build_object('takeover', true, 'personColumn', member->>'person')
|
|
3316
|
+
ELSE '{}'::jsonb END);
|
|
2907
3317
|
EXIT;
|
|
2908
3318
|
END IF;
|
|
2909
3319
|
IF NOT listed THEN below := below || to_jsonb(role_); END IF;
|
|
@@ -3499,12 +3909,32 @@ BEGIN
|
|
|
3499
3909
|
FROM jsonb_array_elements(coalesce(seen->'looped', '[]'::jsonb)) l
|
|
3500
3910
|
GROUP BY l->>'relation') g), '[]'::jsonb);
|
|
3501
3911
|
|
|
3912
|
+
-- Functions anybody can call that run past the rules, and public buckets
|
|
3913
|
+
-- worth a look - read from the app's own shape, never run or opened
|
|
3914
|
+
-- (scan.js privilegedOf and bucketsOf). Found on a blind test
|
|
3915
|
+
-- (HarborLine): neither was ever in the nightly run, so the dashboard
|
|
3916
|
+
-- showed less than the command line did.
|
|
3917
|
+
findings := findings || coalesce((
|
|
3918
|
+
SELECT jsonb_agg(jsonb_build_object(
|
|
3919
|
+
'kind', 'privileged', 'fn', f->>'name', 'table', f->>'name', 'args', f->>'args',
|
|
3920
|
+
'writes', (f->>'writes')::boolean, 'hasFixedSearchPath', (f->>'hasFixedSearchPath')::boolean,
|
|
3921
|
+
'nullSlips', coalesce((f->>'nullSlips')::boolean, false), 'columns', '[]'::jsonb))
|
|
3922
|
+
FROM jsonb_array_elements(coalesce(plan->'anonFunctions', '[]'::jsonb)) f
|
|
3923
|
+
WHERE (f->>'callable')::boolean AND NOT (f->>'aboutCaller')::boolean AND NOT (f->>'guarded')::boolean),
|
|
3924
|
+
'[]'::jsonb)
|
|
3925
|
+
|| __KN__.bucket_findings(plan->'buckets');
|
|
3926
|
+
|
|
3502
3927
|
-- 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.
|
|
3928
|
+
-- fixed from one whose attack simply did not run this time. Every function
|
|
3929
|
+
-- and bucket read counts as looked at, so a revoked grant comes back fixed.
|
|
3504
3930
|
attempted := coalesce(seen->'completed', '[]'::jsonb)
|
|
3505
3931
|
|| coalesce(wrote->'completed', '[]'::jsonb)
|
|
3506
3932
|
|| coalesce(ranked->'completed', '[]'::jsonb)
|
|
3507
|
-
|| coalesce(stranded->'completed', '[]'::jsonb)
|
|
3933
|
+
|| coalesce(stranded->'completed', '[]'::jsonb)
|
|
3934
|
+
|| coalesce((SELECT jsonb_agg('privileged:' || (f->>'name'))
|
|
3935
|
+
FROM jsonb_array_elements(coalesce(plan->'anonFunctions', '[]'::jsonb)) f), '[]'::jsonb)
|
|
3936
|
+
|| coalesce((SELECT jsonb_agg('bucket:' || (b->>'name'))
|
|
3937
|
+
FROM jsonb_array_elements(coalesce(plan->'buckets', '[]'::jsonb)) b), '[]'::jsonb);
|
|
3508
3938
|
|
|
3509
3939
|
-- And everything that was not. Said out loud, never left out: a table
|
|
3510
3940
|
-- nothing could be put into reads exactly like a table nothing got out of.
|