kryptheon-night 0.1.40 → 0.1.41

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/engine.sql CHANGED
@@ -4257,6 +4257,345 @@ RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
4257
4257
  FROM per WHERE all_bare;
4258
4258
  $$;
4259
4259
 
4260
+ /* --------------------------------------------------------------------------
4261
+ Races, read from the app's own functions (races.js says why, and what
4262
+ counts as safe). The same steps in the same order, so the nightly says what
4263
+ the command line says: two callers at once taking the same number
4264
+ (max()+1), both passing a "there is enough" check, or both finding no row
4265
+ and both inserting one. Nothing is called.
4266
+ -------------------------------------------------------------------------- */
4267
+
4268
+ /* The body as statements: comments and string contents gone, lower case, one
4269
+ space, split on ";", a leading BEGIN / END IF / ELSE dropped. */
4270
+ CREATE OR REPLACE FUNCTION __KN__.race_statements(def text)
4271
+ RETURNS text[] LANGUAGE sql IMMUTABLE AS $$
4272
+ SELECT coalesce(array_agg(x ORDER BY o), '{}'::text[])
4273
+ FROM (SELECT regexp_replace(btrim(part), '^(?:(?:begin|else|then|loop|end\s+if|end\s+loop)\s+)+', '') AS x, o
4274
+ FROM unnest(string_to_array(
4275
+ regexp_replace(regexp_replace(
4276
+ regexp_replace(lower(regexp_replace(regexp_replace(regexp_replace(coalesce(def, ''),
4277
+ '/\*.*?\*/', ' ', 'g'), '--[^\n]*', ' ', 'g'), '''(?:[^'']|'''')*''', '''''', 'g')),
4278
+ '\s+', ' ', 'g'),
4279
+ -- only the body: the header up to "as $function$", and the closing tag
4280
+ '^.*?\yas\s+\$[a-z0-9_]*\$\s*', ''), '\s*\$[a-z0-9_]*\$\s*$', ''),
4281
+ ';')) WITH ORDINALITY AS u(part, o)) s
4282
+ WHERE x <> '';
4283
+ $$;
4284
+
4285
+ /* A name as written - public.t, "t", x.col - reduced to its last part. */
4286
+ CREATE OR REPLACE FUNCTION __KN__.race_bare(name text)
4287
+ RETURNS text LANGUAGE sql IMMUTABLE AS $$
4288
+ SELECT coalesce((string_to_array(replace(coalesce(name, ''), '"', ''), '.'))
4289
+ [cardinality(string_to_array(replace(coalesce(name, ''), '"', ''), '.'))], '');
4290
+ $$;
4291
+
4292
+ CREATE OR REPLACE FUNCTION __KN__.race_identifiers(t text)
4293
+ RETURNS text[] LANGUAGE sql IMMUTABLE AS $$
4294
+ SELECT coalesce(array_agg(__KN__.race_bare(m[1]) ORDER BY o), '{}'::text[])
4295
+ FROM regexp_matches(coalesce(t, ''), '([a-z_][a-z0-9_$."]*)', 'g') WITH ORDINALITY AS r(m, o);
4296
+ $$;
4297
+
4298
+ CREATE OR REPLACE FUNCTION __KN__.race_mentions(t text, names text[])
4299
+ RETURNS boolean LANGUAGE sql IMMUTABLE AS $$
4300
+ SELECT EXISTS (SELECT 1 FROM unnest(coalesce(names, '{}'::text[])) v
4301
+ WHERE coalesce(t, '') ~ ('(^|[^a-z0-9_$])' || replace(v, '$', '\$') || '($|[^a-z0-9_$])'));
4302
+ $$;
4303
+
4304
+ /* The text up to the bracket that closes the one already open. */
4305
+ CREATE OR REPLACE FUNCTION __KN__.race_until_close(t text)
4306
+ RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
4307
+ DECLARE
4308
+ depth integer := 0;
4309
+ ch text;
4310
+ BEGIN
4311
+ FOR i IN 1..coalesce(length(t), 0) LOOP
4312
+ ch := substr(t, i, 1);
4313
+ IF ch = '(' THEN
4314
+ depth := depth + 1;
4315
+ ELSIF ch = ')' THEN
4316
+ IF depth = 0 THEN RETURN substr(t, 1, i - 1); END IF;
4317
+ depth := depth - 1;
4318
+ END IF;
4319
+ END LOOP;
4320
+ RETURN coalesce(t, '');
4321
+ END $$;
4322
+
4323
+ /* Column sets a second caller cannot repeat: primary keys, uniques, unique indexes. */
4324
+ CREATE OR REPLACE FUNCTION __KN__.race_unique_sets(plan jsonb, tab text)
4325
+ RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
4326
+ SELECT coalesce(jsonb_agg(to_jsonb(s)) FILTER (WHERE cardinality(s) > 0), '[]'::jsonb)
4327
+ FROM (
4328
+ SELECT __KN__.race_identifiers(lower(substring(k->>'definition' FROM '\(([^)]*)\)'))) AS s
4329
+ FROM jsonb_array_elements(coalesce(plan->'tables', '[]'::jsonb)) t,
4330
+ jsonb_array_elements(coalesce(t->'constraints', '[]'::jsonb)) k
4331
+ WHERE t->>'name' = tab AND k->>'kind' IN ('u', 'p')
4332
+ AND substring(k->>'definition' FROM '\(([^)]*)\)') IS NOT NULL
4333
+ UNION ALL
4334
+ SELECT __KN__.race_identifiers(__KN__.race_until_close(substring(lower(i->>'definition') FROM '\yusing\s+\w+\s*\((.*)$')))
4335
+ FROM jsonb_array_elements(coalesce(plan->'indexes', '[]'::jsonb)) i
4336
+ WHERE i->>'table_name' = tab AND lower(i->>'definition') ~ '\yusing\s+\w+\s*\('
4337
+ ) u;
4338
+ $$;
4339
+
4340
+ /* FROM t [alias] WHERE ..., for the first table named. The alias is never a
4341
+ keyword (races.js fromWhere). */
4342
+ CREATE OR REPLACE FUNCTION __KN__.race_from_where(stmt text, OUT tab text, OUT cond text)
4343
+ LANGUAGE plpgsql IMMUTABLE AS $$
4344
+ DECLARE
4345
+ m text[];
4346
+ BEGIN
4347
+ m := regexp_match(stmt, '\yfrom\s+("?[a-z_][a-z0-9_$."]*)(?:\s+(?:as\s+)?(?!(?:where|for|limit|order|group|join|left|right|inner|cross|into|on|returning|union|then)\y)[a-z_][a-z0-9_]*)?(?:\s+where\s+(.*))?');
4348
+ IF m IS NULL THEN RETURN; END IF;
4349
+ tab := __KN__.race_bare(m[1]);
4350
+ cond := __KN__.race_until_close(regexp_replace(coalesce(m[2], ''),
4351
+ '\y(for\s+(update|no key update|share|key share)|limit|order\s+by|group\s+by|returning)\y.*$', ''));
4352
+ END $$;
4353
+
4354
+ /* The columns a WHERE pins with "col = something", once each, in order. */
4355
+ CREATE OR REPLACE FUNCTION __KN__.race_pins(cond text)
4356
+ RETURNS text[] LANGUAGE sql IMMUTABLE AS $$
4357
+ SELECT coalesce(array_agg(p ORDER BY p COLLATE "C"), '{}'::text[])
4358
+ FROM (SELECT DISTINCT __KN__.race_bare(m[1]) AS p
4359
+ FROM regexp_matches(coalesce(cond, ''), '("?[a-z_][a-z0-9_$."]*)\s*=(?!=)', 'g') AS r(m)) x;
4360
+ $$;
4361
+
4362
+ CREATE OR REPLACE FUNCTION __KN__.race_insert_into(stmts text[], from_ integer, tab text, OUT at_ integer, OUT on_conflict boolean)
4363
+ LANGUAGE plpgsql IMMUTABLE AS $$
4364
+ DECLARE
4365
+ m text[];
4366
+ BEGIN
4367
+ on_conflict := false;
4368
+ FOR j IN from_..coalesce(array_length(stmts, 1), 0) LOOP
4369
+ m := regexp_match(stmts[j], '\yinsert\s+into\s+("?[a-z_][a-z0-9_$."]*)');
4370
+ IF m IS NOT NULL AND __KN__.race_bare(m[1]) = tab THEN
4371
+ at_ := j;
4372
+ on_conflict := stmts[j] ~ '\yon\s+conflict\s*(\(|on\s+constraint\y)';
4373
+ RETURN;
4374
+ END IF;
4375
+ END LOOP;
4376
+ END $$;
4377
+
4378
+ /* SET a = <expr>, b = <expr>: each target with what it is set to. */
4379
+ CREATE OR REPLACE FUNCTION __KN__.race_sets(list text)
4380
+ RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
4381
+ DECLARE
4382
+ n integer := coalesce(length(list), 0);
4383
+ depth integer := 0;
4384
+ cut integer := 1;
4385
+ ch text;
4386
+ m text[];
4387
+ out_ jsonb := '[]'::jsonb;
4388
+ BEGIN
4389
+ FOR k IN 1..n + 1 LOOP
4390
+ ch := CASE WHEN k <= n THEN substr(list, k, 1) END;
4391
+ IF ch = '(' THEN
4392
+ depth := depth + 1;
4393
+ ELSIF ch = ')' THEN
4394
+ depth := depth - 1;
4395
+ ELSIF (ch = ',' AND depth = 0) OR k = n + 1 THEN
4396
+ m := regexp_match(substr(list, cut, k - cut), '^\s*("?[a-z_][a-z0-9_$."]*)\s*=(.*)$');
4397
+ IF m IS NOT NULL THEN
4398
+ out_ := out_ || jsonb_build_array(jsonb_build_object('column', __KN__.race_bare(m[1]), 'expr', m[2]));
4399
+ END IF;
4400
+ cut := k + 1;
4401
+ END IF;
4402
+ END LOOP;
4403
+ RETURN out_;
4404
+ END $$;
4405
+
4406
+ /* SELECT <list> INTO <vars> FROM ..., split by position (races.js selectInto). */
4407
+ CREATE OR REPLACE FUNCTION __KN__.race_select_into(stmt text, OUT list text, OUT vars text)
4408
+ LANGUAGE plpgsql IMMUTABLE AS $$
4409
+ DECLARE
4410
+ rest text;
4411
+ after_ text;
4412
+ a integer;
4413
+ b integer;
4414
+ BEGIN
4415
+ IF left(stmt, 7) <> 'select ' THEN RETURN; END IF;
4416
+ rest := substr(stmt, 8);
4417
+ a := strpos(rest, ' into ');
4418
+ IF a = 0 THEN RETURN; END IF;
4419
+ after_ := substr(rest, a + 6);
4420
+ IF left(after_, 7) = 'strict ' THEN after_ := substr(after_, 8); END IF;
4421
+ b := strpos(after_, ' from ');
4422
+ IF b = 0 THEN RETURN; END IF;
4423
+ list := substr(rest, 1, a - 1);
4424
+ vars := substr(after_, 1, b - 1);
4425
+ END $$;
4426
+
4427
+ /* UPDATE t SET <list> [WHERE <cond>], split at the first " where ". */
4428
+ CREATE OR REPLACE FUNCTION __KN__.race_update_of(stmt text, OUT tab text, OUT list text, OUT cond text)
4429
+ LANGUAGE plpgsql IMMUTABLE AS $$
4430
+ DECLARE
4431
+ m text[];
4432
+ w integer;
4433
+ BEGIN
4434
+ m := regexp_match(stmt, '^update\s+(?:only\s+)?("?[a-z_][a-z0-9_$."]*)\s+(?:as\s+[a-z_][a-z0-9_]*\s+)?set\s+(.*)$');
4435
+ IF m IS NULL THEN RETURN; END IF;
4436
+ tab := __KN__.race_bare(m[1]);
4437
+ w := strpos(m[2], ' where ');
4438
+ list := CASE WHEN w = 0 THEN m[2] ELSE substr(m[2], 1, w - 1) END;
4439
+ cond := CASE WHEN w = 0 THEN '' ELSE substr(m[2], w + 7) END;
4440
+ END $$;
4441
+
4442
+ /* Every race shape in the app's functions, each marked safe or not
4443
+ (races.js raceShapesOf). */
4444
+ CREATE OR REPLACE FUNCTION __KN__.race_shapes(plan jsonb)
4445
+ RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
4446
+ DECLARE
4447
+ row_lock constant text := '\yfor\s+(update|no\s+key\s+update|share|key\s+share)\y';
4448
+ tables text[] := ARRAY(SELECT t->>'name' FROM jsonb_array_elements(coalesce(plan->'tables', '[]'::jsonb)) t);
4449
+ out_ jsonb := '[]'::jsonb;
4450
+ fn jsonb;
4451
+ stmts text[];
4452
+ n integer;
4453
+ s text;
4454
+ locked boolean;
4455
+ locked_by boolean;
4456
+ mm text[];
4457
+ ex text[];
4458
+ src record;
4459
+ ins record;
4460
+ sel record;
4461
+ up record;
4462
+ look record;
4463
+ st jsonb;
4464
+ cols text[];
4465
+ checks text[];
4466
+ col text;
4467
+ into_ text[];
4468
+ setm text[];
4469
+ carried text[];
4470
+ handed boolean;
4471
+ selected text[];
4472
+ vars text[];
4473
+ pins text[];
4474
+ held boolean;
4475
+ branches boolean;
4476
+ safe_ boolean;
4477
+ x text;
4478
+ BEGIN
4479
+ FOR fn IN SELECT f FROM jsonb_array_elements(coalesce(plan->'functions', '[]'::jsonb)) f LOOP
4480
+ CONTINUE WHEN coalesce((fn->>'from_extension')::boolean, false);
4481
+ CONTINUE WHEN coalesce(fn->>'language', '') <> '' AND fn->>'language' !~ '^(plpgsql|sql)$';
4482
+ stmts := __KN__.race_statements(fn->>'def');
4483
+ n := coalesce(array_length(stmts, 1), 0);
4484
+ locked := array_to_string(stmts, '; ') ~
4485
+ '\y(pg_advisory_xact_lock|pg_advisory_lock|pg_try_advisory_xact_lock|pg_try_advisory_lock|lock\s+table|isolation\s+level\s+serializable)\y';
4486
+ FOR i IN 1..n LOOP
4487
+ s := stmts[i];
4488
+ locked_by := EXISTS (SELECT 1 FROM unnest(stmts[1:i]) y WHERE y ~ row_lock);
4489
+
4490
+ -- number
4491
+ mm := regexp_match(s, '^(.*?)(\ymax\s*\()');
4492
+ IF mm IS NOT NULL AND substr(s, length(mm[1]) + 1) ~ '\)\s*\+\s*1\y' AND s ~ '\yinto\y' THEN
4493
+ SELECT * INTO src FROM __KN__.race_from_where(substr(s, length(mm[1]) + 1));
4494
+ CONTINUE WHEN src.tab IS NULL OR NOT (src.tab = ANY (tables));
4495
+ cols := ARRAY(SELECT lower(c->>'name') FROM jsonb_array_elements(coalesce(plan->'tables', '[]'::jsonb)) t,
4496
+ jsonb_array_elements(coalesce(t->'columns', '[]'::jsonb)) c WHERE t->>'name' = src.tab);
4497
+ col := (SELECT c FROM unnest(__KN__.race_identifiers(__KN__.race_until_close(
4498
+ substr(s, length(mm[1]) + length(mm[2]) + 1)))) WITH ORDINALITY AS u(c, o)
4499
+ WHERE c = ANY (cols) ORDER BY o LIMIT 1);
4500
+ CONTINUE WHEN col IS NULL;
4501
+ SELECT * INTO ins FROM __KN__.race_insert_into(stmts, i + 1, src.tab);
4502
+ into_ := regexp_match(s, '\yinto\s+([a-z_][a-z0-9_$]*)\s+from\y');
4503
+ carried := CASE WHEN into_ IS NULL THEN '{}'::text[] ELSE ARRAY[into_[1]] END;
4504
+ handed := false;
4505
+ FOR k IN i + 1..n LOOP
4506
+ x := stmts[k];
4507
+ setm := regexp_match(x, '^([a-z_][a-z0-9_$]*)\s*:=');
4508
+ IF setm IS NOT NULL AND __KN__.race_mentions(x, carried) THEN
4509
+ carried := carried || setm[1];
4510
+ ELSIF x ~ '^return\y' AND __KN__.race_mentions(x, carried) THEN
4511
+ handed := true;
4512
+ EXIT;
4513
+ END IF;
4514
+ END LOOP;
4515
+ CONTINUE WHEN ins.at_ IS NULL AND NOT handed;
4516
+ safe_ := locked OR locked_by OR (ins.at_ IS NOT NULL AND ins.on_conflict)
4517
+ OR EXISTS (SELECT 1 FROM jsonb_array_elements(__KN__.race_unique_sets(plan, src.tab)) u WHERE u ? col);
4518
+ out_ := out_ || jsonb_build_array(jsonb_build_object('fn', fn->>'name', 'args', coalesce(fn->>'args', ''),
4519
+ 'how', 'number', 'table', src.tab, 'column', col, 'safe', safe_));
4520
+ CONTINUE;
4521
+ END IF;
4522
+
4523
+ SELECT * INTO sel FROM __KN__.race_select_into(s);
4524
+
4525
+ -- overdraw
4526
+ IF sel.list IS NOT NULL THEN
4527
+ SELECT * INTO src FROM __KN__.race_from_where(s);
4528
+ IF src.tab IS NOT NULL AND src.tab = ANY (tables) THEN
4529
+ selected := __KN__.race_identifiers(sel.list);
4530
+ vars := __KN__.race_identifiers(sel.vars);
4531
+ cols := ARRAY(SELECT lower(c->>'name') FROM jsonb_array_elements(coalesce(plan->'tables', '[]'::jsonb)) t,
4532
+ jsonb_array_elements(coalesce(t->'columns', '[]'::jsonb)) c WHERE t->>'name' = src.tab);
4533
+ checks := ARRAY(SELECT lower(coalesce(k->>'definition', '')) FROM jsonb_array_elements(coalesce(plan->'tables', '[]'::jsonb)) t,
4534
+ jsonb_array_elements(coalesce(t->'constraints', '[]'::jsonb)) k
4535
+ WHERE t->>'name' = src.tab AND k->>'kind' = 'c');
4536
+ FOR j IN i + 1..n LOOP
4537
+ SELECT * INTO up FROM __KN__.race_update_of(stmts[j]);
4538
+ CONTINUE WHEN up.tab IS NULL OR up.tab <> src.tab;
4539
+ CONTINUE WHEN NOT EXISTS (SELECT 1 FROM unnest(stmts[i + 1:j - 1]) y
4540
+ WHERE y ~ '\yraise\y' AND __KN__.race_mentions(y, vars));
4541
+ FOR st IN SELECT e FROM jsonb_array_elements(__KN__.race_sets(up.list)) e LOOP
4542
+ CONTINUE WHEN NOT ((st->>'column') = ANY (selected));
4543
+ held := __KN__.race_mentions(st->>'expr', ARRAY[st->>'column'])
4544
+ AND EXISTS (SELECT 1 FROM unnest(checks) c
4545
+ WHERE __KN__.race_mentions(c, ARRAY[st->>'column'])
4546
+ AND ((st->>'expr') ~ '-'
4547
+ OR EXISTS (SELECT 1 FROM unnest(__KN__.race_identifiers(c)) y
4548
+ WHERE y <> st->>'column' AND y = ANY (cols))));
4549
+ safe_ := locked OR locked_by OR __KN__.race_mentions(up.cond, ARRAY[st->>'column']) OR held;
4550
+ out_ := out_ || jsonb_build_array(jsonb_build_object('fn', fn->>'name', 'args', coalesce(fn->>'args', ''),
4551
+ 'how', 'overdraw', 'table', src.tab, 'column', st->>'column', 'safe', safe_));
4552
+ END LOOP;
4553
+ END LOOP;
4554
+ END IF;
4555
+ END IF;
4556
+
4557
+ -- double
4558
+ ex := regexp_match(s, '^(.*?)\yexists\s*\(\s*select\y');
4559
+ IF ex IS NOT NULL THEN
4560
+ SELECT * INTO look FROM __KN__.race_from_where(substr(s, length(ex[1]) + 1));
4561
+ ELSIF sel.list IS NOT NULL AND sel.list !~ '\y(count|sum|min|avg|max)\s*\(' THEN
4562
+ SELECT * INTO look FROM __KN__.race_from_where(s);
4563
+ ELSE
4564
+ CONTINUE;
4565
+ END IF;
4566
+ CONTINUE WHEN look.tab IS NULL OR NOT (look.tab = ANY (tables));
4567
+ pins := __KN__.race_pins(look.cond);
4568
+ SELECT * INTO ins FROM __KN__.race_insert_into(stmts, i + 1, look.tab);
4569
+ CONTINUE WHEN cardinality(pins) = 0 OR ins.at_ IS NULL;
4570
+ vars := CASE WHEN ex IS NULL THEN __KN__.race_identifiers(sel.vars) ELSE '{}'::text[] END;
4571
+ branches := CASE WHEN ex IS NOT NULL THEN s ~ '^(if|elsif)\y'
4572
+ ELSE EXISTS (SELECT 1 FROM unnest(stmts[i + 1:ins.at_ - 1]) y
4573
+ WHERE y ~ '^(if|elsif)\y' AND (y ~ '\yfound\y' OR __KN__.race_mentions(y, vars))) END;
4574
+ CONTINUE WHEN NOT branches;
4575
+ safe_ := locked OR locked_by OR ins.on_conflict
4576
+ OR EXISTS (SELECT 1 FROM jsonb_array_elements(__KN__.race_unique_sets(plan, look.tab)) u
4577
+ WHERE NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(u) c WHERE NOT (c = ANY (pins))));
4578
+ out_ := out_ || jsonb_build_array(jsonb_build_object('fn', fn->>'name', 'args', coalesce(fn->>'args', ''),
4579
+ 'how', 'double', 'table', look.tab, 'column', array_to_string(pins, ','), 'safe', safe_));
4580
+ END LOOP;
4581
+ END LOOP;
4582
+ RETURN out_;
4583
+ END $$;
4584
+
4585
+ /* The races to report: shapes nothing makes safe, one per function, how,
4586
+ table and column (races.js racesOf). */
4587
+ CREATE OR REPLACE FUNCTION __KN__.race_findings(plan jsonb)
4588
+ RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
4589
+ SELECT coalesce(jsonb_agg(jsonb_build_object('kind', 'race', 'how', how, 'fn', fn, 'args', args,
4590
+ 'table', tab, 'column', col, 'columns', '[]'::jsonb)
4591
+ ORDER BY fn COLLATE "C", how COLLATE "C", tab COLLATE "C", col COLLATE "C"), '[]'::jsonb)
4592
+ FROM (SELECT x->>'fn' AS fn, x->>'how' AS how, x->>'table' AS tab, x->>'column' AS col,
4593
+ (array_agg(x->>'args' ORDER BY o))[1] AS args, bool_or(NOT (x->>'safe')::boolean) AS open_
4594
+ FROM jsonb_array_elements(__KN__.race_shapes(plan)) WITH ORDINALITY AS a(x, o)
4595
+ GROUP BY 1, 2, 3, 4) g
4596
+ WHERE open_;
4597
+ $$;
4598
+
4260
4599
  /* The ones of those that still take an empty value. */
4261
4600
  CREATE OR REPLACE FUNCTION __KN__.empty_choice_findings(plan jsonb)
4262
4601
  RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
@@ -4534,6 +4873,7 @@ BEGIN
4534
4873
  || refused
4535
4874
  || __KN__.empty_choice_findings(plan)
4536
4875
  || __KN__.no_insert_findings(plan)
4876
+ || __KN__.race_findings(plan)
4537
4877
  || __KN__.bucket_findings(plan->'buckets');
4538
4878
 
4539
4879
  -- Everything genuinely attacked, so a re-check can tell a finding that was
@@ -4555,7 +4895,11 @@ BEGIN
4555
4895
  FROM jsonb_array_elements(__KN__.one_of_columns(plan)) x), '[]'::jsonb)
4556
4896
  || coalesce((SELECT jsonb_agg(DISTINCT 'noinsert:' || (p->>'table_name'))
4557
4897
  FROM jsonb_array_elements(coalesce(plan->'policies', '[]'::jsonb)) p
4558
- WHERE upper(coalesce(p->>'cmd', 'ALL')) IN ('INSERT', 'ALL')), '[]'::jsonb);
4898
+ WHERE upper(coalesce(p->>'cmd', 'ALL')) IN ('INSERT', 'ALL')), '[]'::jsonb)
4899
+ -- Every race shape read, safe or not, so a unique key added
4900
+ -- comes back from a re-check as fixed (races.js raceKeysOf).
4901
+ || coalesce((SELECT jsonb_agg(DISTINCT 'race:' || (x->>'table') || ':' || (x->>'column'))
4902
+ FROM jsonb_array_elements(__KN__.race_shapes(plan)) x), '[]'::jsonb);
4559
4903
 
4560
4904
  -- And everything that was not. Said out loud, never left out: a table
4561
4905
  -- nothing could be put into reads exactly like a table nothing got out of.
package/finding.js CHANGED
@@ -118,6 +118,11 @@ function listOf(items) {
118
118
  return list.slice(0, -1).join(', ') + ' and ' + list[list.length - 1];
119
119
  }
120
120
 
121
+ /** The columns a race finding is about: "organization_id,provider_ref" as a list. */
122
+ function raceColumns(finding) {
123
+ return String(finding.column || '').split(',').filter(Boolean);
124
+ }
125
+
121
126
  /** One of a table's things: workspaces -> workspace, companies -> company. */
122
127
  /** A role, quoted, with its article: a "viewer", an "analyst". */
123
128
  function aRole(role) {
@@ -164,7 +169,8 @@ function severityOf(finding, contents) {
164
169
  // or a bucket may be public, is the owner's decision. So never CRITICAL.
165
170
  return 'HIGH';
166
171
  }
167
- if (finding.kind === 'conflict' || finding.kind === 'searchpath' || finding.kind === 'nullcheck' || finding.kind === 'noinsert') {
172
+ if (finding.kind === 'conflict' || finding.kind === 'searchpath' || finding.kind === 'nullcheck' || finding.kind === 'noinsert' ||
173
+ finding.kind === 'race') {
168
174
  // Read from how a function or a column is declared. A refused upsert
169
175
  // breaks the app for whoever calls it; an open search_path and an empty
170
176
  // value are for the owner to weigh. None of them hands a row to anybody.
@@ -424,6 +430,13 @@ function headlineFor(finding) {
424
430
  if (finding.kind === 'noinsert') {
425
431
  return 'Nobody can add a row to your ' + finding.table + ' table: its rule for adding rows checks nothing, and Postgres reads that as no.';
426
432
  }
433
+ if (finding.kind === 'race') {
434
+ return finding.how === 'number'
435
+ ? 'Two calls at once to your ' + finding.fn + ' function can give two ' + finding.table + ' rows the same ' + finding.column + '.'
436
+ : finding.how === 'overdraw'
437
+ ? 'Two calls at once to your ' + finding.fn + ' function can both pass its check on ' + finding.table + '.' + finding.column + '.'
438
+ : 'Two calls at once to your ' + finding.fn + ' function can add the same ' + finding.table + ' row twice.';
439
+ }
427
440
  if (finding.kind === 'role' && finding.takeover) {
428
441
  return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can take another member\'s place in your ' +
429
442
  finding.table + ' table - and with it their role.';
@@ -700,6 +713,34 @@ function bodyFor(finding, contents) {
700
713
  'from how the rule is written; I did not insert anything.'
701
714
  );
702
715
  }
716
+ if (finding.kind === 'race') {
717
+ // Read from the function's code (races.js); one person clicking never
718
+ // sees it, which is why it was missed by every blind test before this.
719
+ const never = ' One person clicking through the app never sees this; two people at the same moment, ' +
720
+ 'or one double-click, can. I did not call the function: this is read from its code.';
721
+ if (finding.how === 'number') {
722
+ return (
723
+ finding.fn + ' works out the next ' + finding.column + ' as the largest one in ' + finding.table +
724
+ ' plus one, and then uses it. Two calls at the same moment both read the same largest number, so both get ' +
725
+ 'the same next one - and nothing stops the second: ' + finding.table + ' has no unique rule on ' +
726
+ finding.column + ', so both rows are kept with one number.' + never
727
+ );
728
+ }
729
+ if (finding.how === 'overdraw') {
730
+ return (
731
+ finding.fn + ' reads ' + finding.column + ' from ' + finding.table + ', refuses when there is not enough, ' +
732
+ 'and then changes it in a separate step. Nothing makes a second call wait for the first (the read is not ' +
733
+ 'FOR UPDATE), the UPDATE does not test ' + finding.column + ' again, and no CHECK on the table catches it - ' +
734
+ 'so two calls at once can both pass the check and together take more than there is.' + never
735
+ );
736
+ }
737
+ return (
738
+ finding.fn + ' looks in ' + finding.table + ' for a row with the same ' + listOf(raceColumns(finding)) +
739
+ ', and adds one only when it finds none. Two calls at once can both find none and both add one, because ' +
740
+ finding.table + ' has no unique rule on ' + (raceColumns(finding).length > 1 ? 'those columns together' : 'that column') +
741
+ ' to refuse the second.' + never
742
+ );
743
+ }
703
744
  if (finding.kind === 'privileged') {
704
745
  // Said as what is true, not as what was done - nothing was executed. The
705
746
  // reach is real; whether it is a mistake is the person's to say, so the
@@ -1222,6 +1263,48 @@ function fixPromptFor(finding) {
1222
1263
  'the column naming who created it equals auth.uid(). Never use WITH CHECK (true): that lets anyone add ' +
1223
1264
  'rows anywhere. Then add one row through the app to make sure it works.',
1224
1265
  ]
1266
+ : finding.kind === 'race'
1267
+ ? [
1268
+ 'My app may have a race condition - please check it rather than assume it.',
1269
+ '',
1270
+ finding.how === 'number'
1271
+ ? 'The database function "' + finding.fn + '(' + (finding.args || '') + ')" works out the next "' +
1272
+ finding.table + '.' + finding.column + '" as max(' + finding.column + ') + 1 and then uses it. Two calls at ' +
1273
+ 'the same moment get the same number, and "' + finding.table + '" has no unique rule on "' + finding.column + '".'
1274
+ : finding.how === 'overdraw'
1275
+ ? 'The database function "' + finding.fn + '(' + (finding.args || '') + ')" reads "' + finding.table + '.' +
1276
+ finding.column + '", raises an error when there is not enough, and then updates it in a separate statement. ' +
1277
+ 'Two calls at the same moment can both pass the check.'
1278
+ : 'The database function "' + finding.fn + '(' + (finding.args || '') + ')" looks for an existing "' +
1279
+ finding.table + '" row with the same ' + listOf(raceColumns(finding).map((c) => '"' + c + '"')) + ' and inserts ' +
1280
+ 'one only when there is none. Two calls at the same moment can both find none and both insert, because "' +
1281
+ finding.table + '" has no unique rule on ' + (raceColumns(finding).length > 1 ? 'those columns together.' : 'that column.'),
1282
+ '',
1283
+ 'First tell me: can this function be called twice at once - two users, two tabs, or a double-click? If it ' +
1284
+ 'only ever runs once at a time (a single background job, for example), change nothing and say so.',
1285
+ '',
1286
+ finding.how === 'number'
1287
+ ? 'If it can: first check there are no duplicates yet (SELECT ' + finding.column + ', count(*) FROM "' +
1288
+ finding.table + '" GROUP BY 1 HAVING count(*) > 1 - add the column that scopes the number, such as the ' +
1289
+ 'organization, if numbers restart per organization). Then add a unique constraint on "' + finding.table +
1290
+ '" (' + finding.column + ') - with that scoping column in front if there is one - and make taking the next ' +
1291
+ 'number safe: either a sequence, a per-organization counter row taken with UPDATE ... SET next = next + 1 ' +
1292
+ 'RETURNING next, or pg_advisory_xact_lock(...) on the organization at the start of the function. Keep ' +
1293
+ 'everything else the function does exactly as it is.'
1294
+ : finding.how === 'overdraw'
1295
+ ? 'If it can: make the check and the change one step. Either read the row with SELECT ... FOR UPDATE, so a ' +
1296
+ 'second call waits for the first, or do the change as one conditional UPDATE that repeats the check in its ' +
1297
+ 'WHERE and raise an error when it changed no row. A CHECK constraint that states the rule on "' +
1298
+ finding.table + '" (for example that ' + finding.column + ' can never go past the amount available) is a good ' +
1299
+ 'second guard. Keep everything else the function does exactly as it is.'
1300
+ : 'If it can: first check there are no duplicates yet. Then add a unique constraint on "' + finding.table +
1301
+ '" (' + raceColumns(finding).join(', ') + ') - or a unique index with a WHERE if only some rows count, such ' +
1302
+ 'as open ones - and change the INSERT to INSERT ... ON CONFLICT DO NOTHING (or catch unique_violation) so ' +
1303
+ 'the second call gets the existing row or a clear error. Keep everything else the function does exactly as it is.',
1304
+ '',
1305
+ 'Then call it twice at the same moment - two tabs, or two requests sent together - and check the result is ' +
1306
+ 'what one call would have made.',
1307
+ ]
1225
1308
  : finding.kind === 'privileged'
1226
1309
  ? [
1227
1310
  'My app may have a security problem - please check it rather than assume it.',
@@ -1445,7 +1528,7 @@ function fixPromptFor(finding) {
1445
1528
 
1446
1529
  // Found and said, but never claimed as a break: the owner decides. Counted
1447
1530
  // apart in the report, never towards the exit code or a clean re-check.
1448
- const TO_CHECK = ['privileged', 'role', 'teamread', 'bucket', 'searchpath', 'nullcheck', 'noinsert'];
1531
+ const TO_CHECK = ['privileged', 'role', 'teamread', 'bucket', 'searchpath', 'nullcheck', 'noinsert', 'race'];
1449
1532
  // Functions, said by their name rather than a table's.
1450
1533
  const ON_A_FUNCTION = ['privileged', 'searchpath', 'conflict'];
1451
1534
 
@@ -1492,6 +1575,9 @@ function describe(finding) {
1492
1575
  secrets: finding.secrets,
1493
1576
  bucket: finding.bucket,
1494
1577
  rules: finding.rules,
1578
+ // Which race a function's code shows - number, overdraw or double
1579
+ // (races.js) - kept so the re-check and the read-back word it the same way.
1580
+ how: finding.how,
1495
1581
  // An organization's rows read from outside it, and a member taking over
1496
1582
  // another member's row: kept so the re-check and the read-back word them
1497
1583
  // the same way the report did.
@@ -1523,6 +1609,9 @@ function describe(finding) {
1523
1609
  : finding.kind === 'noinsert'
1524
1610
  ? 'I read that every rule for adding rows to "' + finding.table + '" (' + listOf((finding.rules || []).map((r) => '"' + r + '"')) +
1525
1611
  ') has neither USING nor WITH CHECK. I did not insert anything.'
1612
+ : finding.kind === 'race'
1613
+ ? 'I read the code of "' + finding.fn + '" and the unique rules on "' + finding.table + '". I did not call it, ' +
1614
+ 'so whether two calls really arrive together depends on how your app is used.'
1526
1615
  : finding.kind === 'recursive'
1527
1616
  ? 'Using ' + listOf((finding.reads || []).map((t) => '"' + t + '"')) + ' stopped with ' +
1528
1617
  loopError(finding) + '.'
@@ -1669,7 +1758,7 @@ function describeAll(findings) {
1669
1758
  // Writes above reads: a table somebody emptied is worse than one they read.
1670
1759
  // A looping rule sits with the reads: it breaks the app for everyone it
1671
1760
  // fails for, which matters more than a duplicate or a stray row.
1672
- const byKind = { writable: 0, exposed: 1, crossed: 2, recursive: 2.5, conflict: 2.6, duplicated: 3, orphaned: 4, role: 4.5, teamread: 4.6, bucket: 4.7, privileged: 5, searchpath: 5.5, noinsert: 5.7, nullcheck: 6 };
1761
+ const byKind = { writable: 0, exposed: 1, crossed: 2, recursive: 2.5, conflict: 2.6, duplicated: 3, orphaned: 4, role: 4.5, teamread: 4.6, bucket: 4.7, privileged: 5, searchpath: 5.5, noinsert: 5.7, race: 5.8, nullcheck: 6 };
1673
1762
  const rank = (d) => (d.severity === 'CRITICAL' ? 0 : 1) * 10 + (byKind[d.kind] === undefined ? 9 : byKind[d.kind]);
1674
1763
  return described.sort((a, b) => rank(a) - rank(b));
1675
1764
  }
package/package.json CHANGED
@@ -1,98 +1,98 @@
1
- {
2
- "name": "kryptheon-night",
3
- "version": "0.1.40",
4
- "description": "Attacks a copy of your Supabase database and tells you in plain English what got in.",
5
- "license": "SEE LICENSE IN LICENSE",
6
- "type": "commonjs",
7
- "bin": {
8
- "kryptheon-night": "bin/kryptheon-night.js"
9
- },
10
- "files": [
11
- "bin/",
12
- "scan.js",
13
- "schema.js",
14
- "attack.js",
15
- "finding.js",
16
- "outside.js",
17
- "collision.js",
18
- "tamper.js",
19
- "orphan.js",
20
- "recheck.js",
21
- "intro.js",
22
- "trouble.js",
23
- "connect.js",
24
- "README.md",
25
- "installer.js",
26
- "sqlengine.js",
27
- "engine.sql",
28
- "store.js"
29
- ],
30
- "engines": {
31
- "node": ">=18.0.0"
32
- },
33
- "scripts": {
34
- "check": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node outside.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node engines.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node shop.check.js && node privileged.check.js && node recursion.check.js && node guarded.check.js && node nightly.check.js && node words.check.js && node helpers.check.js && node deeploop.check.js && node teams.check.js && node roles.check.js && node buckets.check.js && node cli.check.js && node loop.check.js && node report.check.js && node installscript.check.js && node tenants.check.js && node takeover.check.js && node elevated.check.js && node checkfit.check.js && node declared.check.js && node identity.check.js",
35
- "check:dry": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node recheck.check.js && node outside.check.js",
36
- "check:package": "node package.check.js",
37
- "check:store": "node store.check.js",
38
- "check:trouble": "node trouble.check.js",
39
- "check:intro": "node intro.check.js",
40
- "check:report": "node finding.check.js",
41
- "check:nightly-report": "node report.check.js",
42
- "check:outside": "node outside.check.js",
43
- "check:recheck": "node recheck.check.js",
44
- "check:copy": "node schema.check.js",
45
- "check:collision": "node collision.check.js",
46
- "check:loop": "node loop.check.js",
47
- "probe": "node probe-rls.js",
48
- "probe:collision": "node probe-collision.js",
49
- "demo": "node demo.js",
50
- "check:promise": "node untouched.check.js",
51
- "check:blocked": "node blocked.check.js",
52
- "check:external": "node external.check.js",
53
- "check:shapes": "node shapes.check.js",
54
- "check:verdicts": "node verdicts.check.js",
55
- "check:shop": "node shop.check.js",
56
- "check:privileged": "node privileged.check.js",
57
- "check:recursion": "node recursion.check.js",
58
- "check:guarded": "node guarded.check.js",
59
- "check:nightly": "node nightly.check.js",
60
- "check:words": "node words.check.js",
61
- "check:helpers": "node helpers.check.js",
62
- "check:deeploop": "node deeploop.check.js",
63
- "check:teams": "node teams.check.js",
64
- "check:cli": "node cli.check.js",
65
- "check:writes": "node tamper.check.js",
66
- "check:orphans": "node orphans.check.js",
67
- "probe:write": "node probe-write.js",
68
- "check:usable": "node usable.check.js",
69
- "check:guests": "node guests.check.js",
70
- "check:interruption": "node orphan.check.js",
71
- "probe:orphan": "node probe-orphan.js",
72
- "check:twin": "node twin.check.js",
73
- "prepublishOnly": "node package.check.js",
74
- "check:engines": "node engines.check.js",
75
- "check:installer": "node installer.check.js",
76
- "check:roles": "node roles.check.js",
77
- "check:buckets": "node buckets.check.js",
78
- "check:installscript": "node installscript.check.js",
79
- "check:tenants": "node tenants.check.js",
80
- "check:takeover": "node takeover.check.js",
81
- "check:checkfit": "node checkfit.check.js",
82
- "check:identity": "node identity.check.js",
83
- "check:elevated": "node elevated.check.js"
84
- },
85
- "keywords": [
86
- "supabase",
87
- "postgres",
88
- "rls",
89
- "row-level-security",
90
- "security",
91
- "lovable",
92
- "cli"
93
- ],
94
- "dependencies": {
95
- "pg": "^8.23.0"
96
- },
97
- "homepage": "https://kryptheon.tech"
98
- }
1
+ {
2
+ "name": "kryptheon-night",
3
+ "version": "0.1.41",
4
+ "description": "Attacks a copy of your Supabase database and tells you in plain English what got in.",
5
+ "license": "SEE LICENSE IN LICENSE",
6
+ "type": "commonjs",
7
+ "bin": {
8
+ "kryptheon-night": "bin/kryptheon-night.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "scan.js",
13
+ "schema.js",
14
+ "attack.js",
15
+ "finding.js",
16
+ "outside.js",
17
+ "collision.js",
18
+ "tamper.js",
19
+ "orphan.js", "races.js",
20
+ "recheck.js",
21
+ "intro.js",
22
+ "trouble.js",
23
+ "connect.js",
24
+ "README.md",
25
+ "installer.js",
26
+ "sqlengine.js",
27
+ "engine.sql",
28
+ "store.js"
29
+ ],
30
+ "engines": {
31
+ "node": ">=18.0.0"
32
+ },
33
+ "scripts": {
34
+ "check": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node outside.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node engines.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node shop.check.js && node privileged.check.js && node recursion.check.js && node guarded.check.js && node nightly.check.js && node words.check.js && node helpers.check.js && node deeploop.check.js && node teams.check.js && node roles.check.js && node buckets.check.js && node cli.check.js && node loop.check.js && node report.check.js && node installscript.check.js && node tenants.check.js && node takeover.check.js && node elevated.check.js && node checkfit.check.js && node declared.check.js && node races.check.js && node identity.check.js",
35
+ "check:dry": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node recheck.check.js && node outside.check.js",
36
+ "check:package": "node package.check.js",
37
+ "check:store": "node store.check.js",
38
+ "check:trouble": "node trouble.check.js",
39
+ "check:intro": "node intro.check.js",
40
+ "check:report": "node finding.check.js",
41
+ "check:nightly-report": "node report.check.js",
42
+ "check:outside": "node outside.check.js",
43
+ "check:recheck": "node recheck.check.js",
44
+ "check:copy": "node schema.check.js",
45
+ "check:collision": "node collision.check.js",
46
+ "check:loop": "node loop.check.js",
47
+ "probe": "node probe-rls.js",
48
+ "probe:collision": "node probe-collision.js",
49
+ "demo": "node demo.js",
50
+ "check:promise": "node untouched.check.js",
51
+ "check:blocked": "node blocked.check.js",
52
+ "check:external": "node external.check.js",
53
+ "check:shapes": "node shapes.check.js",
54
+ "check:verdicts": "node verdicts.check.js",
55
+ "check:shop": "node shop.check.js",
56
+ "check:privileged": "node privileged.check.js",
57
+ "check:recursion": "node recursion.check.js",
58
+ "check:guarded": "node guarded.check.js",
59
+ "check:nightly": "node nightly.check.js",
60
+ "check:words": "node words.check.js",
61
+ "check:helpers": "node helpers.check.js",
62
+ "check:deeploop": "node deeploop.check.js",
63
+ "check:teams": "node teams.check.js",
64
+ "check:cli": "node cli.check.js",
65
+ "check:writes": "node tamper.check.js",
66
+ "check:orphans": "node orphans.check.js",
67
+ "probe:write": "node probe-write.js",
68
+ "check:usable": "node usable.check.js",
69
+ "check:guests": "node guests.check.js",
70
+ "check:interruption": "node orphan.check.js",
71
+ "probe:orphan": "node probe-orphan.js",
72
+ "check:twin": "node twin.check.js",
73
+ "prepublishOnly": "node package.check.js",
74
+ "check:engines": "node engines.check.js",
75
+ "check:installer": "node installer.check.js",
76
+ "check:roles": "node roles.check.js",
77
+ "check:buckets": "node buckets.check.js",
78
+ "check:installscript": "node installscript.check.js",
79
+ "check:tenants": "node tenants.check.js",
80
+ "check:takeover": "node takeover.check.js",
81
+ "check:checkfit": "node checkfit.check.js",
82
+ "check:identity": "node identity.check.js",
83
+ "check:elevated": "node elevated.check.js"
84
+ },
85
+ "keywords": [
86
+ "supabase",
87
+ "postgres",
88
+ "rls",
89
+ "row-level-security",
90
+ "security",
91
+ "lovable",
92
+ "cli"
93
+ ],
94
+ "dependencies": {
95
+ "pg": "^8.23.0"
96
+ },
97
+ "homepage": "https://kryptheon.tech"
98
+ }
package/races.js ADDED
@@ -0,0 +1,304 @@
1
+ // Races, read from the app's own functions.
2
+ //
3
+ // collision.js explains why a lost update cannot be reported from the schema:
4
+ // whether two requests at once both win depends on code the tool never sees.
5
+ // On Supabase that code is very often in the database - an RPC such as
6
+ // reserve_stock() or create_ticket() - and its text is in the catalogue. So
7
+ // this reads it, and says what the text shows: a step that looks something up
8
+ // and then writes on the strength of it, with nothing that makes a second
9
+ // caller wait or fail. Nothing is run. Every finding is "to check".
10
+ //
11
+ // Three shapes, each missed by a blind test (rounds 11, 15 and 16):
12
+ //
13
+ // number SELECT max(n) + 1 INTO v FROM t, then INSERT INTO t with it - or
14
+ // hand it back for the app to insert - and no unique rule on n:
15
+ // two callers take the same number.
16
+ // (create_ticket, create_work_order, reserve_subscription_code)
17
+ // overdraw SELECT a quantity INTO a variable, RAISE when it is too low,
18
+ // then UPDATE it: two callers both pass the check. (reserve_stock)
19
+ // double look for a row (IF EXISTS, or SELECT ... INTO and an IF on it),
20
+ // then INSERT one, and no unique rule on what was looked up: two
21
+ // callers both find nothing and both insert.
22
+ // (assign_asset, record_receipt)
23
+ //
24
+ // A shape is safe, and only counted as examined, when:
25
+ // - a unique key or unique index covers the columns it depends on (the
26
+ // second caller then fails instead of slipping through);
27
+ // - the INSERT has ON CONFLICT;
28
+ // - for overdraw, the read is FOR UPDATE / FOR SHARE, the UPDATE tests the
29
+ // column again in its WHERE, or a CHECK holds it (see below);
30
+ // - a row lock comes earlier in the function, or it takes an advisory lock,
31
+ // LOCK TABLE or SERIALIZABLE anywhere.
32
+ //
33
+ // Mirrored in engine.sql race_shapes.
34
+
35
+ const LOCKS = /\b(pg_advisory_xact_lock|pg_advisory_lock|pg_try_advisory_xact_lock|pg_try_advisory_lock|lock\s+table|isolation\s+level\s+serializable)\b/;
36
+ const ROW_LOCK = /\bfor\s+(update|no\s+key\s+update|share|key\s+share)\b/;
37
+ // A name as written, quoted or not: "public"."tickets" as well as public.tickets.
38
+ const NAME = '"?[a-z_][a-z0-9_$."]*';
39
+
40
+ /** The body as statements: comments and string contents gone, lower case,
41
+ * one space, split on ";", and a leading BEGIN / END IF / ELSE dropped so a
42
+ * statement starts with what it does. */
43
+ function statementsOf(def) {
44
+ const text = String(def || '')
45
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
46
+ .replace(/--[^\n]*/g, ' ')
47
+ .replace(/'(?:[^']|'')*'/g, "''")
48
+ .toLowerCase()
49
+ .replace(/\s+/g, ' ')
50
+ // Only the body: "create function ... as $function$" and the closing
51
+ // $function$ off. Left on, a function with no DECLARE had its first IF
52
+ // glued to the header and never read as an IF.
53
+ .replace(/^[\s\S]*?\bas\s+\$[a-z0-9_]*\$\s*/, '')
54
+ .replace(/\s*\$[a-z0-9_]*\$\s*$/, '');
55
+ return text.split(';')
56
+ .map((x) => x.trim().replace(/^(?:(?:begin|else|then|loop|end\s+if|end\s+loop)\s+)+/, ''))
57
+ .filter(Boolean);
58
+ }
59
+
60
+ /** A name as written - public.t, "t", x.col - reduced to its last part. */
61
+ function bare(name) {
62
+ const parts = String(name || '').replace(/"/g, '').split('.');
63
+ return parts[parts.length - 1];
64
+ }
65
+
66
+ /** Identifiers in a piece of SQL, each reduced to its last part. */
67
+ function identifiers(text) {
68
+ return (String(text || '').match(/[a-z_][a-z0-9_$."]*/g) || []).map(bare);
69
+ }
70
+
71
+ function mentions(text, names) {
72
+ return names.some((v) => new RegExp('(^|[^a-z0-9_$])' + v.replace(/\$/g, '\\$') + '($|[^a-z0-9_$])').test(text));
73
+ }
74
+
75
+ /** The text up to the bracket that closes the one already open. */
76
+ function untilClose(text) {
77
+ let depth = 0;
78
+ for (let i = 0; i < text.length; i++) {
79
+ if (text[i] === '(') depth++;
80
+ else if (text[i] === ')') { if (depth === 0) return text.slice(0, i); depth--; }
81
+ }
82
+ return text;
83
+ }
84
+
85
+ /** Column sets a second caller cannot repeat: primary keys, uniques, unique indexes. */
86
+ function uniqueSetsOf(plan, table) {
87
+ const sets = [];
88
+ const t = (plan.tables || []).find((x) => x.name === table);
89
+ for (const con of (t && t.constraints) || []) {
90
+ if (con.kind !== 'u' && con.kind !== 'p') continue;
91
+ const inner = /\(([^)]*)\)/.exec(con.definition || '');
92
+ if (inner) sets.push(identifiers(inner[1].toLowerCase()));
93
+ }
94
+ for (const idx of plan.indexes || []) {
95
+ if (idx.table_name !== table) continue;
96
+ // CREATE UNIQUE INDEX i ON public.t USING btree (a, lower(b)) WHERE (...)
97
+ const at = /\busing\s+\w+\s*\(/i.exec(idx.definition || '');
98
+ if (at) sets.push(identifiers(untilClose(String(idx.definition).slice(at.index + at[0].length).toLowerCase())));
99
+ }
100
+ return sets.filter((x) => x.length);
101
+ }
102
+
103
+ function tableOf(plan, table) {
104
+ return (plan.tables || []).find((x) => x.name === table) || { columns: [], constraints: [] };
105
+ }
106
+
107
+ /** FROM t [alias] WHERE ..., for the first table named after a point. The
108
+ * alias is never a keyword: reading "FROM t WHERE" as t aliased "where" lost
109
+ * the condition (assign_asset, record_receipt on round 16). */
110
+ function fromWhere(stmt) {
111
+ const m = new RegExp('\\bfrom\\s+(' + NAME + ')(?:\\s+(?:as\\s+)?(?!(?:where|for|limit|order|group|join|left|right|inner|cross|into|on|returning|union|then)\\b)[a-z_][a-z0-9_]*)?(?:\\s+where\\s+(.*))?').exec(stmt);
112
+ if (!m) return null;
113
+ const where = untilClose((m[2] || '').replace(/\b(for\s+(update|no key update|share|key share)|limit|order\s+by|group\s+by|returning)\b.*$/, ''));
114
+ return { table: bare(m[1]), where: where };
115
+ }
116
+
117
+ /** The columns a WHERE pins with "col = something". */
118
+ function pinnedBy(where) {
119
+ const pins = [];
120
+ const re = new RegExp('(' + NAME + ')\\s*=(?!=)', 'g');
121
+ for (let m; (m = re.exec(where));) pins.push(bare(m[1]));
122
+ return Array.from(new Set(pins)).sort();
123
+ }
124
+
125
+ function insertInto(stmts, from, table) {
126
+ for (let j = from; j < stmts.length; j++) {
127
+ const m = new RegExp('\\binsert\\s+into\\s+(' + NAME + ')').exec(stmts[j]);
128
+ // Only an ON CONFLICT that names its target: Postgres then needs a unique
129
+ // index to plan it, so the second caller is caught. A bare ON CONFLICT DO
130
+ // NOTHING on a table with no unique rule never conflicts at all.
131
+ if (m && bare(m[1]) === table) return { at: j, onConflict: /\bon\s+conflict\s*(\(|on\s+constraint\b)/.test(stmts[j]) };
132
+ }
133
+ return null;
134
+ }
135
+
136
+ /** SET a = <expr>, b = <expr>: each target with what it is set to. */
137
+ function setsOf(list) {
138
+ const out = [];
139
+ let depth = 0, cut = 0;
140
+ for (let k = 0; k <= list.length; k++) {
141
+ const ch = list[k];
142
+ if (ch === '(') depth++;
143
+ else if (ch === ')') depth--;
144
+ else if ((ch === ',' && depth === 0) || k === list.length) {
145
+ const m = new RegExp('^\\s*(' + NAME + ')\\s*=(.*)$').exec(list.slice(cut, k));
146
+ if (m) out.push({ column: bare(m[1]), expr: m[2] });
147
+ cut = k + 1;
148
+ }
149
+ }
150
+ return out;
151
+ }
152
+
153
+ /** SELECT <list> INTO <vars> FROM ...: split at the first " into " and the
154
+ * first " from " after it - by position, not a lazy pattern, so engine.sql
155
+ * (whose regular expressions are not lazy the same way) reads it alike. */
156
+ function selectInto(stmt) {
157
+ if (!stmt.startsWith('select ')) return null;
158
+ const rest = stmt.slice(7);
159
+ const a = rest.indexOf(' into ');
160
+ if (a < 0) return null;
161
+ let after = rest.slice(a + 6);
162
+ if (after.startsWith('strict ')) after = after.slice(7);
163
+ const b = after.indexOf(' from ');
164
+ if (b < 0) return null;
165
+ return { list: rest.slice(0, a), vars: after.slice(0, b) };
166
+ }
167
+
168
+ /** UPDATE t SET <list> [WHERE <cond>], split at the first " where ". */
169
+ function updateOf(stmt) {
170
+ const m = new RegExp('^update\\s+(?:only\\s+)?(' + NAME + ')\\s+(?:as\\s+[a-z_][a-z0-9_]*\\s+)?set\\s+(.*)$').exec(stmt);
171
+ if (!m) return null;
172
+ const w = m[2].indexOf(' where ');
173
+ return { table: bare(m[1]), list: w < 0 ? m[2] : m[2].slice(0, w), where: w < 0 ? '' : m[2].slice(w + 7) };
174
+ }
175
+
176
+ /** Every race shape one function's text shows, each marked safe or not. */
177
+ function shapesInFunction(fn, plan) {
178
+ const out = [];
179
+ const stmts = statementsOf(fn.def);
180
+ const locked = LOCKS.test(stmts.join('; '));
181
+ const tables = new Set((plan.tables || []).map((t) => t.name));
182
+ const lockedBy = (i) => stmts.slice(0, i + 1).some((x) => ROW_LOCK.test(x));
183
+ const add = (how, table, column, safe) => out.push({ how: how, table: table, column: column, safe: locked || safe });
184
+
185
+ stmts.forEach((s, i) => {
186
+ // number
187
+ const max = /\bmax\s*\(/.exec(s);
188
+ if (max && /\)\s*\+\s*1\b/.test(s.slice(max.index)) && /\binto\b/.test(s)) {
189
+ const src = fromWhere(s.slice(max.index));
190
+ if (!src || !tables.has(src.table)) return;
191
+ const cols = new Set(tableOf(plan, src.table).columns.map((c) => String(c.name).toLowerCase()));
192
+ const column = identifiers(untilClose(s.slice(max.index + max[0].length))).find((c) => cols.has(c));
193
+ if (!column) return;
194
+ const ins = insertInto(stmts, i + 1, src.table);
195
+ // Or handed back for the app to insert: reserve_subscription_code()
196
+ // returned "PREFIX-00042" built from it (round 11, HL-04), through any
197
+ // x := ... n ... on the way.
198
+ const into = /\binto\s+([a-z_][a-z0-9_$]*)\s+from\b/.exec(s);
199
+ const carried = into ? [into[1]] : [];
200
+ let handedBack = false;
201
+ for (const x of stmts.slice(i + 1)) {
202
+ const set = /^([a-z_][a-z0-9_$]*)\s*:=/.exec(x);
203
+ if (set && mentions(x, carried)) carried.push(set[1]);
204
+ else if (/^return\b/.test(x) && mentions(x, carried)) { handedBack = true; break; }
205
+ }
206
+ if (!ins && !handedBack) return;
207
+ add('number', src.table, column,
208
+ lockedBy(i) || (ins && ins.onConflict) || uniqueSetsOf(plan, src.table).some((set) => set.includes(column)));
209
+ return;
210
+ }
211
+
212
+ const read = selectInto(s);
213
+
214
+ // overdraw
215
+ if (read) {
216
+ const src = fromWhere(s);
217
+ if (src && tables.has(src.table)) {
218
+ const selected = identifiers(read.list);
219
+ const vars = identifiers(read.vars);
220
+ const tab = tableOf(plan, src.table);
221
+ const cols = new Set(tab.columns.map((c) => String(c.name).toLowerCase()));
222
+ const checks = tab.constraints.filter((c) => c.kind === 'c').map((c) => String(c.definition || '').toLowerCase());
223
+ for (let j = i + 1; j < stmts.length; j++) {
224
+ const up = updateOf(stmts[j]);
225
+ if (!up || up.table !== src.table) continue;
226
+ const refused = stmts.slice(i + 1, j).some((x) => /\braise\b/.test(x) && mentions(x, vars));
227
+ if (!refused) continue;
228
+ for (const { column, expr } of setsOf(up.list)) {
229
+ if (!selected.includes(column)) continue;
230
+ // A CHECK stops the second caller only when the UPDATE works on
231
+ // the column itself (a = a - x) and the CHECK is a floor under the
232
+ // subtraction, or ties the column to another one (reserved <=
233
+ // on_hand). "a = v - x", set from what was read, is the lost
234
+ // update itself and no CHECK sees it; reserve_stock's
235
+ // CHECK (reserved_quantity >= 0) does nothing for an addition.
236
+ const self = mentions(expr, [column]);
237
+ const held = self && checks.some((c) => mentions(c, [column]) &&
238
+ (/-/.test(expr) || identifiers(c).some((x) => x !== column && cols.has(x))));
239
+ add('overdraw', src.table, column, lockedBy(i) || mentions(up.where, [column]) || held);
240
+ }
241
+ }
242
+ }
243
+ }
244
+
245
+ // double
246
+ const exists = /\bexists\s*\(\s*select\b/.exec(s);
247
+ const lookup = exists ? fromWhere(s.slice(exists.index))
248
+ : read && !/\b(count|sum|min|avg|max)\s*\(/.test(read.list) ? fromWhere(s) : null;
249
+ if (!lookup || !tables.has(lookup.table)) return;
250
+ const pins = pinnedBy(lookup.where);
251
+ const ins = insertInto(stmts, i + 1, lookup.table);
252
+ if (!pins.length || !ins) return;
253
+ const vars = read && !exists ? identifiers(read.vars) : [];
254
+ const branches = exists ? /^(if|elsif)\b/.test(s)
255
+ : stmts.slice(i + 1, ins.at).some((x) => /^(if|elsif)\b/.test(x) && (/\bfound\b/.test(x) || mentions(x, vars)));
256
+ if (!branches) return;
257
+ add('double', lookup.table, pins.join(','),
258
+ lockedBy(i) || ins.onConflict || uniqueSetsOf(plan, lookup.table).some((set) => set.every((c) => pins.includes(c))));
259
+ });
260
+ return out;
261
+ }
262
+
263
+ /** Every shape in the app's functions: { fn, args, how, table, column, safe }. */
264
+ function raceShapesOf(plan) {
265
+ const out = [];
266
+ for (const fn of (plan && plan.functions) || []) {
267
+ if (fn.from_extension) continue;
268
+ if (fn.language && !/^(plpgsql|sql)$/.test(String(fn.language))) continue;
269
+ for (const shape of shapesInFunction(fn, plan)) out.push(Object.assign({ fn: fn.name, args: fn.args || '' }, shape));
270
+ }
271
+ return out;
272
+ }
273
+
274
+ const order = (a, b) => {
275
+ const x = a.fn + '\u0000' + a.how + '\u0000' + a.table + '\u0000' + a.column;
276
+ const y = b.fn + '\u0000' + b.how + '\u0000' + b.table + '\u0000' + b.column;
277
+ return x < y ? -1 : x > y ? 1 : 0;
278
+ };
279
+
280
+ /** The races to report: shapes nothing makes safe, one per function, how,
281
+ * table and column. */
282
+ function racesOf(plan) {
283
+ const seen = new Map();
284
+ for (const shape of raceShapesOf(plan)) {
285
+ const key = shape.fn + ':' + shape.how + ':' + shape.table + ':' + shape.column;
286
+ seen.set(key, seen.has(key) ? (seen.get(key) && shape.safe) : shape.safe);
287
+ }
288
+ const out = [];
289
+ for (const shape of raceShapesOf(plan)) {
290
+ const key = shape.fn + ':' + shape.how + ':' + shape.table + ':' + shape.column;
291
+ if (seen.get(key) !== false) continue;
292
+ seen.set(key, true);
293
+ out.push({ kind: 'race', how: shape.how, fn: shape.fn, args: shape.args, table: shape.table, column: shape.column, columns: [] });
294
+ }
295
+ return out.sort(order);
296
+ }
297
+
298
+ /** What counts as examined, for the re-check: every shape read, safe or not,
299
+ * keyed the way a finding is (recheck.js keyOf). */
300
+ function raceKeysOf(plan) {
301
+ return Array.from(new Set(raceShapesOf(plan).map((x) => 'race:' + x.table + ':' + x.column))).sort();
302
+ }
303
+
304
+ module.exports = { racesOf: racesOf, raceKeysOf: raceKeysOf, raceShapesOf: raceShapesOf, statementsOf: statementsOf };
package/recheck.js CHANGED
@@ -233,6 +233,7 @@ function whatOf(item) {
233
233
  if (item.kind === 'conflict') return 'an upsert Postgres refuses';
234
234
  if (item.kind === 'nullcheck') return 'a ' + (item.column || 'value') + ' that can be left empty';
235
235
  if (item.kind === 'noinsert') return 'no row can be added';
236
+ if (item.kind === 'race') return 'two calls at once in ' + (item.fn || 'a function');
236
237
  return item.kind;
237
238
  }
238
239
 
@@ -318,6 +319,9 @@ function describe(result) {
318
319
  : 'I asked Postgres to plan it again, and it accepted it.')
319
320
  : item.kind === 'noinsert'
320
321
  ? (elsewhere ? 'I looked here, and a rule for adding rows checks something.' : 'I looked again, and a rule for adding rows now checks something.')
322
+ : item.kind === 'race'
323
+ ? (elsewhere ? 'I read it here, and something stops a second call - a unique rule, a lock, or the check repeated in the same statement.'
324
+ : 'I read it again, and something now stops a second call - a unique rule, a lock, or the check repeated in the same statement.')
321
325
  : item.kind === 'nullcheck'
322
326
  ? (elsewhere ? 'I looked here, and it cannot be left empty.' : 'I looked again, and it can no longer be left empty.')
323
327
  : item.kind === 'recursive'
package/scan.js CHANGED
@@ -20,6 +20,7 @@ const finding = require('./finding.js');
20
20
  const collision = require('./collision.js');
21
21
  const tamper = require('./tamper.js');
22
22
  const orphan = require('./orphan.js');
23
+ const { racesOf, raceKeysOf } = require('./races.js');
23
24
  const recheck = require('./recheck.js');
24
25
  const store = require('./store.js');
25
26
 
@@ -441,7 +442,12 @@ async function scan(client, sourceSchema, options) {
441
442
  for (const name of new Set((plan.policies || []).filter((p) => /^(INSERT|ALL)$/i.test(String(p.cmd || 'ALL'))).map((p) => p.table_name))) {
442
443
  attempted.push('noinsert:' + name);
443
444
  }
444
- const declared = searchPathsOf(plan).concat(refused, emptyChoicesOf(plan), noInsertsOf(plan));
445
+ // Races the app's own functions show: max()+1, a check then a change, a
446
+ // look then an insert, with nothing to stop a second caller (races.js).
447
+ // Every shape read counts as examined, safe or not, so a unique key added
448
+ // comes back from a re-check as fixed.
449
+ for (const key of raceKeysOf(plan)) attempted.push(key);
450
+ const declared = searchPathsOf(plan).concat(refused, emptyChoicesOf(plan), noInsertsOf(plan), racesOf(plan));
445
451
 
446
452
  // Storage buckets switched to public that also have a rule saying who may
447
453
  // read them. Read from the bucket settings and the rules, never a file.