kryptheon-night 0.1.8 → 0.1.10

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 CHANGED
@@ -31,6 +31,39 @@ const USER_D = '66666666-6666-4666-8666-666666666666';
31
31
  // where the row id IS the person.
32
32
  const OWNER_NAMES = ['user_id', 'owner_id', 'owner', 'profile_id', 'account_id', 'created_by', 'author_id'];
33
33
 
34
+ // A key to Supabase's auth.users as the app wrote it. In the copy it points
35
+ // at a stand-in named kn_ext__auth__users, which the table-of-people rule
36
+ // below already recognises; this one is for a table itself called users.
37
+ const TO_AUTH_USERS = /REFERENCES\s+"?auth"?\s*\.\s*"?users"?\s*\(/i;
38
+ // A table that looks like it holds people, keyed by the person.
39
+ const PERSON_TABLE = /(profile|user|account|member)/i;
40
+
41
+ /**
42
+ * The single-column keys from a uuid column, with what they point at. Read
43
+ * from the constraint wording itself, because a key to auth.users and a key
44
+ * to public.users are different things and the schema is part of the words.
45
+ */
46
+ function personKeys(table, uuids) {
47
+ const byName = new Set(uuids.map((c) => c.name));
48
+ const keys = [];
49
+ for (const constraint of table.constraints || []) {
50
+ if (constraint.kind !== 'f') continue;
51
+ const definition = String(constraint.definition || '');
52
+ const match = /FOREIGN KEY \(([^)]+)\) REFERENCES ([^(]+)\(([^)]+)\)/i.exec(definition);
53
+ if (!match) continue;
54
+ const columns = match[1].split(',').map((t) => t.trim().split('"').join(''));
55
+ const refColumns = match[3].split(',').map((t) => t.trim().split('"').join(''));
56
+ if (columns.length !== 1 || !byName.has(columns[0])) continue;
57
+ keys.push({
58
+ column: columns[0],
59
+ toAuth: TO_AUTH_USERS.test(definition),
60
+ refTable: match[2].split('.').pop().trim().split('"').join(''),
61
+ refColumn: refColumns[0],
62
+ });
63
+ }
64
+ return keys;
65
+ }
66
+
34
67
  /** The column that says who a row belongs to, or null if nothing does. */
35
68
  function ownerColumn(table) {
36
69
  const uuids = table.columns.filter((c) => c.type === 'uuid');
@@ -38,6 +71,19 @@ function ownerColumn(table) {
38
71
  const found = uuids.find((c) => c.name === name);
39
72
  if (found) return found.name;
40
73
  }
74
+ // Not named like an owner, but pointing at a person. Found on a blind test:
75
+ // appointments.provider_id -> profiles.id -> auth.users. Judged by name
76
+ // alone it had no owner, so "one provider reads another's patients" - a
77
+ // rule written USING (true) - was never tried, and the worst hole in the
78
+ // app went unreported. `id` first: a table keyed by the person is theirs.
79
+ // Two people on one row - sender_id and recipient_id - and neither is
80
+ // named: whose row it is cannot be told, and guessing would report one
81
+ // person reading a message sent to them as a break. So only one, or none.
82
+ const people = personKeys(table, uuids).filter((k) => k.toAuth ||
83
+ (k.refColumn === 'id' && k.refTable !== table.name && PERSON_TABLE.test(k.refTable)));
84
+ const columns = Array.from(new Set(people.map((k) => k.column)));
85
+ if (columns.includes('id')) return 'id';
86
+ if (columns.length === 1) return columns[0];
41
87
  // A profiles table keyed by the person themselves.
42
88
  if (/profile|user|account|member/i.test(table.name)) {
43
89
  const id = uuids.find((c) => c.name === 'id');
@@ -561,12 +561,15 @@ async function main() {
561
561
  if (!before) {
562
562
  scanner.report(result);
563
563
  scanner.saveRun(file, result);
564
- process.exitCode = result.stopped ? 2 : result.findings.length ? 1 : 0;
564
+ process.exitCode = scanner.exitCodeFor(result);
565
565
  } else {
566
566
  const verdict = recheck.compare(before, result);
567
567
  recheck.describe(verdict).forEach(line);
568
568
  recheck.badgeLines(verdict, result.attacksRun || 0).forEach(line);
569
+ // The report ends with what was not tested; with nothing left to report
570
+ // that list still has to be printed, or the re-check goes quiet about it.
569
571
  if (result.findings.length) scanner.report(result);
572
+ else scanner.notTestedLines(result).forEach(line);
570
573
  scanner.saveRun(file, result);
571
574
  process.exitCode = result.stopped ? 2 : verdict.allClear ? 0 : 1;
572
575
  }
package/engine.sql CHANGED
@@ -1006,6 +1006,7 @@ RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
1006
1006
  DECLARE
1007
1007
  wanted text;
1008
1008
  found text;
1009
+ people text[];
1009
1010
  BEGIN
1010
1011
  FOREACH wanted IN ARRAY ARRAY['user_id', 'owner_id', 'owner', 'profile_id',
1011
1012
  'account_id', 'created_by', 'author_id'] LOOP
@@ -1016,6 +1017,28 @@ BEGIN
1016
1017
  IF found IS NOT NULL THEN RETURN found; END IF;
1017
1018
  END LOOP;
1018
1019
 
1020
+ -- Not named like an owner, but a single-column key from a uuid column to a
1021
+ -- person: auth.users (or the copy's stand-in for it), or the id of a table
1022
+ -- that holds people. Found on a blind test: appointments.provider_id ->
1023
+ -- profiles.id was never tried. `id` first; two such columns (sender_id and
1024
+ -- recipient_id) cannot say whose row it is, so they name no owner.
1025
+ SELECT array_agg(DISTINCT k.col) INTO people
1026
+ FROM (
1027
+ SELECT __KN__.unquoted(p[1]) AS col,
1028
+ (con->>'definition') ~* 'REFERENCES\s+"?auth"?\s*\.\s*"?users"?\s*\(' AS to_auth,
1029
+ __KN__.unquoted(split_part(p[2], '.', greatest(array_length(string_to_array(p[2], '.'), 1), 1))) AS ref_table,
1030
+ __KN__.unquoted(p[3]) AS ref_column
1031
+ FROM jsonb_array_elements(coalesce(tab->'constraints', '[]'::jsonb)) con,
1032
+ regexp_match(con->>'definition', 'FOREIGN KEY \(([^),]+)\) REFERENCES ([^(]+)\(([^),]+)\)', 'i') p
1033
+ WHERE con->>'kind' = 'f' AND p IS NOT NULL
1034
+ ) k
1035
+ WHERE EXISTS (SELECT 1 FROM jsonb_array_elements(tab->'columns') c
1036
+ WHERE c->>'type' = 'uuid' AND c->>'name' = k.col)
1037
+ AND (k.to_auth OR (k.ref_column = 'id' AND k.ref_table <> tab->>'name'
1038
+ AND k.ref_table ~* '(profile|user|account|member)'));
1039
+ IF 'id' = ANY (coalesce(people, '{}')) THEN RETURN 'id'; END IF;
1040
+ IF array_length(people, 1) = 1 THEN RETURN people[1]; END IF;
1041
+
1019
1042
  -- A profiles table keyed by the person themselves.
1020
1043
  IF (tab->>'name') ~* '(profile|user|account|member)' THEN
1021
1044
  SELECT c->>'name' INTO found
@@ -1862,6 +1885,25 @@ END $$;
1862
1885
  * `seeded` carries the shape of row that worked when the table was seeded, so
1863
1886
  * the insert here is not rejected by some CHECK the seeder already solved.
1864
1887
  */
1888
+ /*
1889
+ * Whose name an insert is written under: nobody's yet (user C), unless the
1890
+ * owner column is a key into one of the app's own tables, where only the two
1891
+ * seeded people exist - then the one who is not the signed-in caller.
1892
+ */
1893
+ CREATE OR REPLACE FUNCTION __KN__.write_as_for(tab jsonb, owner text, tables jsonb)
1894
+ RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
1895
+ BEGIN
1896
+ IF owner IS NOT NULL AND EXISTS (
1897
+ SELECT 1 FROM jsonb_array_elements(__KN__.foreign_keys(tab)) k
1898
+ WHERE jsonb_array_length(k->'columns') = 1 AND k->'columns'->>0 = owner
1899
+ AND EXISTS (SELECT 1 FROM jsonb_array_elements(coalesce(tables, '[]'::jsonb)) t
1900
+ WHERE t->>'name' = k->>'refTable'))
1901
+ THEN
1902
+ RETURN __KN__.user_a();
1903
+ END IF;
1904
+ RETURN __KN__.user_c();
1905
+ END $$;
1906
+
1865
1907
  CREATE OR REPLACE FUNCTION __KN__.tamper(source text, tables jsonb, seeded jsonb,
1866
1908
  policies jsonb DEFAULT '[]'::jsonb)
1867
1909
  RETURNS jsonb LANGUAGE plpgsql AS $$
@@ -1925,7 +1967,10 @@ BEGIN
1925
1967
  -- Written under a name nobody has used, which is both what makes the row
1926
1968
  -- land at all and what makes it the right test: adding a row of your own
1927
1969
  -- is the feature, adding one under somebody else's name is not.
1928
- row_ := __KN__.row_for(source, tab, __KN__.user_c(), '7', NULL, shape);
1970
+ -- Unless the owner is a key into one of the app's own tables, where only
1971
+ -- the two seeded people exist: then the one who is not the signed-in
1972
+ -- caller. Found on a blind test, where the nameless insert hit the key.
1973
+ row_ := __KN__.row_for(source, tab, __KN__.write_as_for(tab, owner, tables), '7', NULL, shape);
1929
1974
 
1930
1975
  -- Every statement written out before anybody changes role.
1931
1976
  moves := jsonb_build_array(
package/finding.js CHANGED
@@ -72,6 +72,14 @@ function listOf(items) {
72
72
  * should be fixed - there is no such thing as a low finding here.
73
73
  */
74
74
  function severityOf(finding, contents) {
75
+ if (finding.kind === 'privileged') {
76
+ // Not proven, so never CRITICAL: this is a reach that exists, not a break
77
+ // that was demonstrated. A function anon can call that also writes is worth
78
+ // more attention than one that only reads, but both stay HIGH and both
79
+ // carry "verification required" - the person has to say whether the anon
80
+ // call was intended.
81
+ return 'HIGH';
82
+ }
75
83
  if (finding.kind === 'orphaned') {
76
84
  // Nothing is exposed and nothing is destroyed, so this is not the same
77
85
  // order of thing as a table anyone can empty. It stays serious because
@@ -90,7 +98,13 @@ function severityOf(finding, contents) {
90
98
  // the table. A second row holding the same session token is critical even
91
99
  // if the table has nothing personal in it at all.
92
100
  if (finding.expectation === 'credential') return 'CRITICAL';
93
- if (finding.expectation === 'identity') return 'CRITICAL';
101
+ // Critical only while this column could be the login itself. When the
102
+ // database has Supabase's auth.users, sign-in goes through that table,
103
+ // which keeps every email unique - so a duplicate here is a copy that
104
+ // disagrees with itself, not two accounts sharing one login. Found on a
105
+ // real app: the report said "two accounts can answer to the same login"
106
+ // about a profiles table the login never read.
107
+ if (finding.expectation === 'identity') return finding.loginElsewhere ? 'HIGH' : 'CRITICAL';
94
108
  return 'HIGH';
95
109
  }
96
110
  if (contents.secrets.length) return 'CRITICAL';
@@ -247,6 +261,9 @@ function headlineFor(finding) {
247
261
  if (finding.kind === 'duplicated') {
248
262
  return 'Your ' + finding.table + ' table lets the same ' + finding.column + ' exist twice.';
249
263
  }
264
+ if (finding.kind === 'privileged') {
265
+ return 'Anyone on the internet can call your ' + finding.fn + ' function, and it runs with full rights.';
266
+ }
250
267
  if (finding.kind === 'exposed') {
251
268
  return 'Your ' + finding.table + ' ' + (finding.isView ? 'view' : 'table') + ' can be read by anyone.';
252
269
  }
@@ -263,6 +280,17 @@ const COST_OF_A_DUPLICATE = {
263
280
  code: 'A one-time code that can exist twice can be redeemed twice.',
264
281
  };
265
282
 
283
+ // Said instead of the login sentence when sign-in happens in auth.users.
284
+ const COST_OF_A_COPIED_IDENTITY =
285
+ 'Signing in is not affected: your app signs people in through auth.users, ' +
286
+ 'which keeps each email unique. But anything that looks a person up by this ' +
287
+ 'column can get two rows back and pick the wrong one.';
288
+
289
+ function costOf(finding) {
290
+ if (finding.expectation === 'identity' && finding.loginElsewhere) return COST_OF_A_COPIED_IDENTITY;
291
+ return COST_OF_A_DUPLICATE[finding.expectation] || '';
292
+ }
293
+
266
294
  function bodyFor(finding, contents) {
267
295
  const holds = listOf(contents.secrets.concat(contents.identity, contents.money));
268
296
  const rowWord = finding.readable === 1 ? 'row' : 'rows';
@@ -300,14 +328,32 @@ function bodyFor(finding, contents) {
300
328
  'kept, and your live app was never touched.';
301
329
  }
302
330
 
331
+ if (finding.kind === 'privileged') {
332
+ // Said as what is true, not as what was done - nothing was executed. The
333
+ // reach is real; whether it is a mistake is the person's to say, so the
334
+ // wording asks rather than accuses.
335
+ return (
336
+ 'This function is SECURITY DEFINER, so it runs as whoever created it, not ' +
337
+ 'as the visitor calling it - which means the row level security on the tables ' +
338
+ 'it touches is checked against the owner and skipped for the caller. It is ' +
339
+ 'granted to anon, the role a visitor with no account uses, so anyone on the ' +
340
+ 'internet can call it' +
341
+ (finding.writes ? ' - and it writes to your data, so a caller can change rows the rules would otherwise protect.'
342
+ : ', reaching whatever it reads past the rules on those tables.') +
343
+ ' I did not call it: whether an open function like this is intended is ' +
344
+ 'something only you can confirm.' +
345
+ (finding.hasFixedSearchPath ? ''
346
+ : ' It also does not pin its search_path, so a caller can point the names ' +
347
+ 'inside it at their own objects.')
348
+ );
349
+ }
303
350
  if (finding.kind === 'duplicated') {
304
351
  // Said as what was done, not as what it implies. Two connections, one
305
352
  // moment, both accepted, and here is the count afterwards.
306
353
  return (
307
354
  'I opened two connections to a copy of your app and inserted the same ' +
308
355
  finding.column + ' from both at the same moment. Both were accepted, so there are ' +
309
- 'now ' + finding.copies + ' rows holding the identical value. ' +
310
- (COST_OF_A_DUPLICATE[finding.expectation] || '')
356
+ 'now ' + finding.copies + ' rows holding the identical value. ' + costOf(finding)
311
357
  ).trim();
312
358
  }
313
359
 
@@ -364,11 +410,146 @@ function wrapTo(text, width) {
364
410
  return out;
365
411
  }
366
412
 
413
+ /**
414
+ * Whether the app signs people in through Supabase: any foreign key to
415
+ * auth.users, or any rule that asks auth.uid() or auth.jwt(). Decided for the
416
+ * whole app, not table by table. Found on a blind test: appointments points
417
+ * at profiles, which points at auth.users - no key of its own and no rule
418
+ * asking auth.uid(), so it was told the app "signs people in its own way"
419
+ * when it plainly used Supabase's. Read from the original schema, not the
420
+ * copy - the copy points those keys at a stand-in. A table missing from the
421
+ * map is unknown, which the prompts treat as tied.
422
+ */
423
+ function authTiesOf(plan) {
424
+ const tables = (plan && plan.tables) || [];
425
+ const keyed = tables.some((table) => (table.constraints || []).some((c) =>
426
+ c.kind === 'f' && /references\s+"?auth"?\s*\.\s*"?users"?\s*\(/i.test(String(c.definition || ''))));
427
+ const asked = ((plan && plan.policies) || []).some((policy) =>
428
+ /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(String(policy.qual || '') + ' ' + String(policy.with_check || '')));
429
+ return new Map(tables.map((table) => [table.name, keyed || asked]));
430
+ }
431
+
432
+ /**
433
+ * The fix for a table no rule can tie to the signed-in user, or null when one
434
+ * can. Two ways that happens. The app has its own login: found on a real app,
435
+ * "compare the owner to the id of the signed-in user" cannot be done there -
436
+ * auth.uid() is empty for everybody - and an assistant that writes it anyway
437
+ * locks the app out of its own table. Or the table has no column saying whose
438
+ * row it is - a waitlist, a contact form - so there is nothing to compare.
439
+ */
440
+ function serverOnlyLines(finding) {
441
+ const table = finding.table;
442
+ let why;
443
+ if (finding.authTied === false) {
444
+ why = 'Nothing in "' + table + '" points at auth.users and no rule on it uses auth.uid(), so your ' +
445
+ 'app signs people in its own way. A rule comparing a column to the signed-in user cannot ' +
446
+ 'work here - auth.uid() is empty for everyone - and adding one would lock your own app out.';
447
+ } else if (finding.owned === false) {
448
+ why = '"' + table + '" has no column that says whose row it is, so a rule tying rows to the ' +
449
+ 'signed-in user has nothing to compare against.';
450
+ } else {
451
+ return null;
452
+ }
453
+ return [
454
+ why,
455
+ '',
456
+ 'If only your server reads and writes this table: keep row level security on, drop the rules ' +
457
+ 'that let everyone in, and add none for anon or authenticated. Have your server use the ' +
458
+ 'service_role key, which skips these rules - and keep that key on the server only, never ' +
459
+ 'in the browser or in the app\'s public code.',
460
+ '',
461
+ 'If the browser needs to read or write part of it directly, tell me which part first, so we ' +
462
+ 'write the narrowest rule for exactly that.',
463
+ '',
464
+ ];
465
+ }
466
+
467
+ /**
468
+ * One prompt for everything wrong with one table's rules - read and write,
469
+ * strangers and customers - because it is one rule to replace, not four.
470
+ */
471
+ function groupPromptFor(members) {
472
+ const lead = members.find((m) => m.kind === 'writable') || members[0];
473
+ const cause = causeOf(lead);
474
+ const table = lead.table;
475
+ const owner = (members.find((m) => m.owner) || {}).owner;
476
+ const ownerWords = owner ? '"' + owner + '"' : 'the column that says who each row belongs to';
477
+ const read = members.find((m) => m.kind === 'exposed');
478
+ const writers = members.filter((m) => m.kind === 'writable');
479
+ const anyoneWrites = writers.find((w) => w.who === 'anyone');
480
+ const customersWrite = writers.find((w) => w.who !== 'anyone');
481
+ const what = [];
482
+ if (read) what.push('read');
483
+ if (writers.length) what.push('written');
484
+ const who = read || anyoneWrites ? 'anyone who is not logged in' : 'any signed-in user, including other users\' rows';
485
+ const did = [];
486
+ if (read) did.push('read ' + read.readable + ' ' + (read.readable === 1 ? 'row' : 'rows'));
487
+ const moves = [];
488
+ for (const w of writers) for (const m of w.can || []) if (!moves.includes(m)) moves.push(m);
489
+ for (const m of ['add', 'change', 'delete']) {
490
+ if (moves.includes(m)) did.push({ add: 'added a row', change: 'changed rows', delete: 'deleted rows' }[m]);
491
+ }
492
+ const lines = [
493
+ 'My app has a security problem.',
494
+ '',
495
+ 'The "' + table + '" table can be ' + what.join(' and ') + ' by ' + who +
496
+ ', because ' + cause.short + '. I proved it on a copy: I ' + listOf(did) + '.' +
497
+ (anyoneWrites && customersWrite ? ' Signed-in users can do the same.' : ''),
498
+ '',
499
+ ].concat(
500
+ serverOnlyLines(lead) || [
501
+ 'Switch row level security on for this table, remove the rule that lets everyone in, and ' +
502
+ 'write separate rules for reading, inserting, updating and deleting. A rule written FOR ALL ' +
503
+ 'covers far more than reading.',
504
+ '',
505
+ 'Each of them should compare ' + ownerWords + ' to the id of the signed-in user (auth.uid()): ' +
506
+ 'USING for reading, updating and deleting, WITH CHECK for inserting and updating, so nobody ' +
507
+ 'reads, changes or writes a row under somebody else\'s name. Give logged-out visitors no rule ' +
508
+ 'at all unless they truly need one.',
509
+ '',
510
+ ],
511
+ ['Then check every other table for the same thing.'],
512
+ );
513
+ return lines
514
+ .map((paragraph) => (paragraph ? wrapTo(paragraph, 72) : ['']))
515
+ .reduce((all, part) => all.concat(part), [])
516
+ .join('\n');
517
+ }
518
+
367
519
  function fixPromptFor(finding) {
368
520
  const cause = causeOf(finding);
369
521
  const owner = finding.owner ? '"' + finding.owner + '"' : 'the column that says who each row belongs to';
370
522
 
371
- const lines = finding.kind === 'orphaned'
523
+ const lines = finding.kind === 'privileged'
524
+ ? [
525
+ 'My app may have a security problem - please check it rather than assume it.',
526
+ '',
527
+ 'The database function "' + finding.fn + '(' + (finding.args || '') + ')" is SECURITY ' +
528
+ 'DEFINER, so it runs with its owner\'s rights and ignores row level security. It is ' +
529
+ 'granted EXECUTE to "anon", so anyone who is not logged in can call it' +
530
+ (finding.writes ? ', and it writes to tables.' : '.'),
531
+ '',
532
+ 'First tell me: is it meant to be callable by logged-out visitors? If it is not, ' +
533
+ 'REVOKE EXECUTE ON FUNCTION "' + finding.fn + '"(' + (finding.args || '') + ') FROM anon, PUBLIC, ' +
534
+ 'and GRANT EXECUTE only to the role that should run it - usually service_role, the ' +
535
+ 'backend key, the way an admin-only function like this should be reached.',
536
+ '',
537
+ ].concat(
538
+ finding.hasFixedSearchPath
539
+ ? []
540
+ : [
541
+ 'It also does not set a fixed search_path. Add SET search_path = pg_catalog, public (or ' +
542
+ 'the schemas it truly needs) so a caller cannot make it resolve names to their own objects.',
543
+ '',
544
+ ],
545
+ [
546
+ 'If it is meant to be public, keep it, but make sure everything inside it checks what the ' +
547
+ 'caller is allowed to see or change itself - the table rules will not do it here.',
548
+ '',
549
+ 'Then look at every other SECURITY DEFINER function for the same grant.',
550
+ ],
551
+ )
552
+ : finding.kind === 'orphaned'
372
553
  ? [
373
554
  'My app has a data problem.',
374
555
  '',
@@ -399,17 +580,24 @@ function fixPromptFor(finding) {
399
580
  (finding.can || []).map((what) => ({ add: 'added a row', change: 'changed rows', delete: 'deleted rows' })[what])
400
581
  .join(', ') + '.',
401
582
  '',
402
- 'Switch row level security on for this table, then write separate rules for ' +
403
- 'reading, inserting, updating and deleting. A rule written FOR SELECT does ' +
404
- 'not cover writes, and a rule written FOR ALL covers far more than reading.',
405
- '',
406
- 'For insert and update, use WITH CHECK comparing ' + owner +
407
- ' to the id of the signed-in user, so nobody can write a row under somebody ' +
408
- "else's name.",
409
- '',
410
- 'Then check every other table for the same thing - a table with no row level ' +
411
- 'security on it is writable by default.',
412
- ]
583
+ ].concat(
584
+ serverOnlyLines(finding) || [
585
+ 'Switch row level security on for this table, then write separate rules for ' +
586
+ 'reading, inserting, updating and deleting. A rule written FOR SELECT does ' +
587
+ 'not cover writes, and a rule written FOR ALL covers far more than reading.',
588
+ '',
589
+ 'For insert and update, use WITH CHECK comparing ' + owner +
590
+ ' to the id of the signed-in user, so nobody can write a row under somebody ' +
591
+ "else's name. For update and delete, add USING with the same comparison: " +
592
+ 'with USING (true) left on the update, anyone can take a row that is not ' +
593
+ 'theirs and rewrite it as their own.',
594
+ '',
595
+ ],
596
+ [
597
+ 'Then check every other table for the same thing - a table with no row level ' +
598
+ 'security on it is writable by default.',
599
+ ],
600
+ )
413
601
  : finding.kind === 'duplicated'
414
602
  ? [
415
603
  'My app has a security problem.',
@@ -418,17 +606,50 @@ function fixPromptFor(finding) {
418
606
  '". I proved it by inserting the same value from two connections at the same ' +
419
607
  'moment, and both were accepted.',
420
608
  '',
421
- 'Add a unique constraint on "' + finding.table + '"."' + finding.column +
422
- '" in the database itself. Checking in application code before inserting is not ' +
423
- 'enough - between the check and the insert, the other request has already gone in.',
609
+ // First, because it has to happen before anything is changed. Learned
610
+ // by applying the short version of this prompt literally to a real app:
611
+ // the constraint cannot be created while duplicates exist, and an
612
+ // assistant told only "add a constraint" may delete rows to get there.
613
+ 'First, find the rows that already share a value and show them to me. Do not ' +
614
+ 'delete or merge any of them without asking me: the rule below cannot be ' +
615
+ 'created while they exist, and deleting rows to make it pass loses real data.',
616
+ '',
617
+ (finding.expectation === 'identity'
618
+ ? 'Then add a unique index on lower("' + finding.column + '"), so that "' + finding.table +
619
+ '"."' + finding.column + '" is unique whatever the case - Ann@example.com and ' +
620
+ 'ann@example.com are the same person.'
621
+ : 'Then add a unique constraint on "' + finding.table + '"."' + finding.column + '".') +
622
+ ' It has to be in the database itself. Checking in application code before inserting ' +
623
+ 'is not enough - between the check and the insert, the other request has already gone in.',
424
624
  '',
425
625
  // Said because the obvious fix is wrong for multi-tenant apps, and being
426
626
  // told to drop a legitimate design would cost them more than the bug.
427
- 'If the same value is allowed to repeat for different owners, make the constraint ' +
428
- 'cover both columns together rather than leaving it off.',
627
+ 'If the same value is allowed to repeat for different owners, make it cover both ' +
628
+ 'columns together rather than leaving it off.',
429
629
  '',
430
- 'Then look for the same missing constraint on every other table and fix those too.',
431
- ]
630
+ ].concat(
631
+ [
632
+ 'Find everything that writes this column - app code, and any database trigger or ' +
633
+ 'function, such as one that copies each new sign-up into this table - and make ' +
634
+ 'sure each one handles "this value already exists" instead of failing. If people ' +
635
+ 'can edit this column themselves, decide whether they should: once it is unique, ' +
636
+ 'someone who puts another person\'s value in first will block that person.',
637
+ '',
638
+ ],
639
+ finding.expectation === 'identity' && finding.loginElsewhere
640
+ ? [
641
+ 'Sign-in uses auth.users, so this column is only a copy of the email there. ' +
642
+ 'Consider not letting people edit it at all and keeping it in step with auth.users.',
643
+ '',
644
+ ]
645
+ : [],
646
+ [
647
+ 'Afterwards, sign up a brand-new account and edit an existing profile to make sure ' +
648
+ 'both still work, and run this check again.',
649
+ '',
650
+ 'Then look for the same missing constraint on every other table and fix those too.',
651
+ ],
652
+ )
432
653
  : finding.isView
433
654
  ? [
434
655
  'My app has a security problem.',
@@ -451,11 +672,14 @@ function fixPromptFor(finding) {
451
672
  ? 'can be read by anyone who is not logged in, because ' + cause.short + '.'
452
673
  : 'lets one signed-in user read rows belonging to a different user, because ' + cause.short + '.'),
453
674
  '',
454
- 'Fix it so a person can only read their own rows: compare ' + owner +
455
- ' against the id of the signed-in user, and make sure logged-out visitors get nothing.',
456
- '',
457
- 'Then look for the same mistake on every other table and fix those too.',
458
- ];
675
+ ].concat(
676
+ serverOnlyLines(finding) || [
677
+ 'Fix it so a person can only read their own rows: compare ' + owner +
678
+ ' against the id of the signed-in user, and make sure logged-out visitors get nothing.',
679
+ '',
680
+ ],
681
+ ['Then look for the same mistake on every other table and fix those too.'],
682
+ );
459
683
  // Wrapped here rather than by whoever prints it: this text is pasted into a
460
684
  // chat box as often as it is read in a terminal, and an unwrapped paragraph
461
685
  // is a wall in both.
@@ -471,7 +695,15 @@ function describe(finding) {
471
695
  const cause = causeOf(finding);
472
696
  return {
473
697
  severity: severityOf(finding, contents),
474
- table: finding.table,
698
+ // Every other finding here was proven by an attack that ran; this is the
699
+ // one the tool reasons about without executing, so it says so. The report
700
+ // and the re-check both read this: a "verification required" finding is
701
+ // never counted towards a clean re-check on its own.
702
+ status: finding.kind === 'privileged' ? 'verification required' : 'confirmed',
703
+ table: finding.kind === 'privileged' ? finding.fn : finding.table,
704
+ fn: finding.fn,
705
+ args: finding.args,
706
+ writes: finding.writes,
475
707
  kind: finding.kind,
476
708
  // Carried through because a table can have two different columns that each
477
709
  // accept a duplicate, and the re-check tells one finding from another by
@@ -483,10 +715,15 @@ function describe(finding) {
483
715
  expectation: finding.expectation,
484
716
  headline: headlineFor(finding, contents),
485
717
  body: bodyFor(finding, contents),
486
- cause: cause.long,
718
+ // For a privileged function there is no "why the door is open" the way a
719
+ // table has one; the body already carries the whole explanation, so the
720
+ // cause line would only repeat it. Left empty and skipped by the report.
721
+ cause: finding.kind === 'privileged' ? '' : cause.long,
487
722
  who: finding.who,
488
723
  can: finding.can,
489
- proof: finding.kind === 'duplicated'
724
+ proof: finding.kind === 'privileged'
725
+ ? 'I did not call "' + finding.fn + '". This is a reach that exists in the grants, not a break I ran.'
726
+ : finding.kind === 'duplicated'
490
727
  ? 'I created ' + finding.copies + ' rows in "' + finding.table + '" holding the same ' +
491
728
  finding.column + '.'
492
729
  : finding.countOnly
@@ -499,6 +736,50 @@ function describe(finding) {
499
736
  };
500
737
  }
501
738
 
739
+ /** Everything wrong with one table's rules, said once. */
740
+ function mergeTable(group, one) {
741
+ const members = group.map(one);
742
+ const read = group.find((m) => m.kind === 'exposed');
743
+ const anyoneWrites = group.find((m) => m.kind === 'writable' && m.who === 'anyone');
744
+ const customersWrite = group.find((m) => m.kind === 'writable' && m.who !== 'anyone');
745
+ const lead = anyoneWrites || customersWrite || read;
746
+ const leadItem = members[group.indexOf(lead)];
747
+ const table = lead.table;
748
+ const order = ['delete', 'change', 'add'];
749
+ const does = (w) => order.filter((m) => (w.can || []).includes(m)).map((m) => WRITE_WORDS[m]);
750
+ let headline;
751
+ if (read && anyoneWrites) {
752
+ const verbs = ['read'].concat(order.filter((m) => (anyoneWrites.can || []).includes(m)));
753
+ headline = 'Anyone can ' + listOf(verbs) + ' rows in your ' + table + ' table.';
754
+ } else if (read && customersWrite) {
755
+ headline = 'Anyone can read your ' + table + ' table, and any signed-in customer can ' + listOf(does(customersWrite)) + ' it.';
756
+ } else {
757
+ headline = leadItem.headline;
758
+ }
759
+ // The customers' part is said in one line when strangers can already do it.
760
+ const bodies = [];
761
+ for (let i = 0; i < group.length; i++) {
762
+ if (group[i] === customersWrite && anyoneWrites) continue;
763
+ bodies.push(members[i].body);
764
+ }
765
+ if (customersWrite && anyoneWrites) bodies.push('Signed-in customers can do the same.');
766
+ const severity = members.some((m) => m.severity === 'CRITICAL') ? 'CRITICAL' : leadItem.severity;
767
+ const can = [];
768
+ for (const m of group) for (const c of m.can || []) if (!can.includes(c)) can.push(c);
769
+ return Object.assign({}, leadItem, {
770
+ severity: severity,
771
+ status: 'confirmed',
772
+ kind: lead.kind,
773
+ who: read || anyoneWrites ? 'anyone' : leadItem.who,
774
+ can: can,
775
+ headline: headline,
776
+ body: bodies.join(' '),
777
+ proof: members.map((m) => m.proof).join(' '),
778
+ fixPrompt: groupPromptFor(group),
779
+ members: members,
780
+ });
781
+ }
782
+
502
783
  /** The whole report, in the order a person should read it. */
503
784
  function describeAll(findings) {
504
785
  // A table anyone can read is also, necessarily, a table one customer can read
@@ -507,24 +788,44 @@ function describeAll(findings) {
507
788
  // is kept, and it carries the rest.
508
789
  const raw = findings || [];
509
790
  const publiclyOpen = new Set(raw.filter((f) => f.kind === 'exposed').map((f) => f.table));
510
- const described = raw
511
- .filter((f) => !(f.kind === 'crossed' && publiclyOpen.has(f.table)))
512
- .map((f) =>
513
- Object.assign(describe(f), {
514
- alsoCrossed:
515
- f.kind === 'exposed' && raw.some((o) => o.kind === 'crossed' && o.table === f.table),
516
- }),
517
- );
518
- for (const item of described) {
519
- if (item.alsoCrossed) {
520
- item.body += ' Your signed-in customers can read each other\'s rows for the same reason.';
791
+ const kept = raw.filter((f) => !(f.kind === 'crossed' && publiclyOpen.has(f.table)));
792
+ const one = (f) => {
793
+ const item = Object.assign(describe(f), {
794
+ alsoCrossed: f.kind === 'exposed' && raw.some((o) => o.kind === 'crossed' && o.table === f.table),
795
+ });
796
+ if (item.alsoCrossed) item.body += ' Your signed-in customers can read each other\'s rows for the same reason.';
797
+ return item;
798
+ };
799
+
800
+ // One table, one finding. A single rule written FOR ALL USING (true) opens a
801
+ // table to reading and writing, for strangers and customers alike - four
802
+ // proofs of one mistake. Listed as four, a real app's report showed six
803
+ // CRITICAL findings for two rules, and the person reads six problems where
804
+ // there are two to fix. The proofs are kept as members, so a re-check can
805
+ // still tell "strangers can no longer write" from "strangers can still read".
806
+ const grouped = new Map();
807
+ for (const f of kept) {
808
+ if ((f.kind !== 'exposed' && f.kind !== 'writable') || f.isView || f.countOnly) continue;
809
+ if (!grouped.has(f.table)) grouped.set(f.table, []);
810
+ grouped.get(f.table).push(f);
811
+ }
812
+ const described = [];
813
+ const done = new Set();
814
+ for (const f of kept) {
815
+ const group = grouped.get(f.table);
816
+ if (!group || group.length < 2 || group.indexOf(f) === -1) {
817
+ described.push(one(f));
818
+ continue;
521
819
  }
820
+ if (done.has(f.table)) continue;
821
+ done.add(f.table);
822
+ described.push(mergeTable(group, one));
522
823
  }
523
824
  // Worst first, and within that the ones open to the whole internet before the
524
825
  // ones that need an account, and those before the ones that need two requests
525
826
  // to arrive together.
526
827
  // Writes above reads: a table somebody emptied is worse than one they read.
527
- const byKind = { writable: 0, exposed: 1, crossed: 2, duplicated: 3, orphaned: 4 };
828
+ const byKind = { writable: 0, exposed: 1, crossed: 2, duplicated: 3, orphaned: 4, privileged: 5 };
528
829
  const rank = (d) => (d.severity === 'CRITICAL' ? 0 : 1) * 10 + (byKind[d.kind] === undefined ? 9 : byKind[d.kind]);
529
830
  return described.sort((a, b) => rank(a) - rank(b));
530
831
  }
@@ -557,6 +858,7 @@ function allClearLines(attacksRun) {
557
858
  }
558
859
 
559
860
  module.exports = {
861
+ authTiesOf: authTiesOf,
560
862
  readContents: readContents,
561
863
  severityOf: severityOf,
562
864
  causeOf: causeOf,