kryptheon-night 0.1.9 → 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');
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
@@ -410,6 +410,112 @@ function wrapTo(text, width) {
410
410
  return out;
411
411
  }
412
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
+
413
519
  function fixPromptFor(finding) {
414
520
  const cause = causeOf(finding);
415
521
  const owner = finding.owner ? '"' + finding.owner + '"' : 'the column that says who each row belongs to';
@@ -474,17 +580,24 @@ function fixPromptFor(finding) {
474
580
  (finding.can || []).map((what) => ({ add: 'added a row', change: 'changed rows', delete: 'deleted rows' })[what])
475
581
  .join(', ') + '.',
476
582
  '',
477
- 'Switch row level security on for this table, then write separate rules for ' +
478
- 'reading, inserting, updating and deleting. A rule written FOR SELECT does ' +
479
- 'not cover writes, and a rule written FOR ALL covers far more than reading.',
480
- '',
481
- 'For insert and update, use WITH CHECK comparing ' + owner +
482
- ' to the id of the signed-in user, so nobody can write a row under somebody ' +
483
- "else's name.",
484
- '',
485
- 'Then check every other table for the same thing - a table with no row level ' +
486
- 'security on it is writable by default.',
487
- ]
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
+ )
488
601
  : finding.kind === 'duplicated'
489
602
  ? [
490
603
  'My app has a security problem.',
@@ -559,11 +672,14 @@ function fixPromptFor(finding) {
559
672
  ? 'can be read by anyone who is not logged in, because ' + cause.short + '.'
560
673
  : 'lets one signed-in user read rows belonging to a different user, because ' + cause.short + '.'),
561
674
  '',
562
- 'Fix it so a person can only read their own rows: compare ' + owner +
563
- ' against the id of the signed-in user, and make sure logged-out visitors get nothing.',
564
- '',
565
- 'Then look for the same mistake on every other table and fix those too.',
566
- ];
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
+ );
567
683
  // Wrapped here rather than by whoever prints it: this text is pasted into a
568
684
  // chat box as often as it is read in a terminal, and an unwrapped paragraph
569
685
  // is a wall in both.
@@ -620,6 +736,50 @@ function describe(finding) {
620
736
  };
621
737
  }
622
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
+
623
783
  /** The whole report, in the order a person should read it. */
624
784
  function describeAll(findings) {
625
785
  // A table anyone can read is also, necessarily, a table one customer can read
@@ -628,18 +788,38 @@ function describeAll(findings) {
628
788
  // is kept, and it carries the rest.
629
789
  const raw = findings || [];
630
790
  const publiclyOpen = new Set(raw.filter((f) => f.kind === 'exposed').map((f) => f.table));
631
- const described = raw
632
- .filter((f) => !(f.kind === 'crossed' && publiclyOpen.has(f.table)))
633
- .map((f) =>
634
- Object.assign(describe(f), {
635
- alsoCrossed:
636
- f.kind === 'exposed' && raw.some((o) => o.kind === 'crossed' && o.table === f.table),
637
- }),
638
- );
639
- for (const item of described) {
640
- if (item.alsoCrossed) {
641
- 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;
642
819
  }
820
+ if (done.has(f.table)) continue;
821
+ done.add(f.table);
822
+ described.push(mergeTable(group, one));
643
823
  }
644
824
  // Worst first, and within that the ones open to the whole internet before the
645
825
  // ones that need an account, and those before the ones that need two requests
@@ -678,6 +858,7 @@ function allClearLines(attacksRun) {
678
858
  }
679
859
 
680
860
  module.exports = {
861
+ authTiesOf: authTiesOf,
681
862
  readContents: readContents,
682
863
  severityOf: severityOf,
683
864
  causeOf: causeOf,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon-night",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Attacks a copy of your Supabase database and tells you in plain English what got in.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
package/recheck.js CHANGED
@@ -42,9 +42,20 @@ function keyOf(item) {
42
42
  * untestable table would land in `fixed`, and a green badge would be handed to
43
43
  * an app nobody checked.
44
44
  */
45
+ /**
46
+ * A report shows one finding per table, but each one keeps the proofs it was
47
+ * made of. Those are what get compared: closing a table to strangers' writes
48
+ * while it stays readable is one proof fixed and one still open, not "the
49
+ * table's finding" either fixed or not. Reports saved before findings were
50
+ * grouped have no members, and are read as they are.
51
+ */
52
+ function proofsOf(list) {
53
+ return (list || []).reduce((all, item) => all.concat(item.members && item.members.length ? item.members : [item]), []);
54
+ }
55
+
45
56
  function compare(before, after) {
46
- const was = (before && before.findings) || [];
47
- const now = (after && after.findings) || [];
57
+ const was = proofsOf(before && before.findings);
58
+ const now = proofsOf(after && after.findings);
48
59
 
49
60
  // The second run failed outright. Nothing can be claimed about anything.
50
61
  if (after && after.stopped) {
@@ -149,6 +160,21 @@ function badgeLines(result, attacksRun) {
149
160
  ];
150
161
  }
151
162
 
163
+ /**
164
+ * Which proof a line is about. One table can have several - strangers reading
165
+ * it, strangers writing to it - and "waitlist, waitlist" under "fixed" would
166
+ * not say which one was closed.
167
+ */
168
+ function whatOf(item) {
169
+ if (item.kind === 'exposed') return 'read by anyone';
170
+ if (item.kind === 'crossed') return "customers reading each other's rows";
171
+ if (item.kind === 'writable') return item.who === 'anyone' ? 'written by anyone' : 'written by any signed-in customer';
172
+ if (item.kind === 'duplicated') return 'the same ' + (item.column || 'value') + ' twice';
173
+ if (item.kind === 'orphaned') return 'rows left pointing at nothing';
174
+ if (item.kind === 'privileged') return 'a function anyone can call';
175
+ return item.kind;
176
+ }
177
+
152
178
  /** What the re-check says, in the order it matters. */
153
179
  function describe(result) {
154
180
  const lines = [''];
@@ -165,7 +191,7 @@ function describe(result) {
165
191
  lines.push(' ' + result.fixed.length + (result.fixed.length === 1 ? ' problem is' : ' problems are') + ' fixed:');
166
192
  lines.push('');
167
193
  for (const item of result.fixed) {
168
- lines.push(' ' + item.table + ' - I ran the same attack again and it was refused.');
194
+ lines.push(' ' + item.table + ' (' + whatOf(item) + ') - I ran the same attack again and it was refused.');
169
195
  }
170
196
  lines.push('');
171
197
  }
@@ -185,7 +211,7 @@ function describe(result) {
185
211
  lines.push(' ' + result.unverifiable.length + ' I could NOT confirm:');
186
212
  lines.push('');
187
213
  for (const item of result.unverifiable) {
188
- lines.push(' ' + item.table + ' - ' + item.why);
214
+ lines.push(' ' + item.table + ' (' + whatOf(item) + ') - ' + item.why);
189
215
  }
190
216
  lines.push('');
191
217
  lines.push(' These are not fixed and not broken - they are unknown. The problem');
package/scan.js CHANGED
@@ -372,13 +372,24 @@ async function scan(client, sourceSchema, options) {
372
372
  columns: [],
373
373
  }));
374
374
 
375
+ // Whether each table is tied to Supabase's sign-in decides what its fix
376
+ // can be. Read from the original schema: the copy points auth.users keys
377
+ // at a stand-in, and would call every table untied.
378
+ const ties = finding.authTiesOf(plan);
379
+ // And whether a row says whose it is: with no such column, no rule can
380
+ // tie it to the person asking. Read from the copy, the way seeding did.
381
+ const owned = new Map(theirs.map((table) => [table.name, Boolean(attack.ownerColumn(table))]));
382
+ const tagged = (f) => Object.assign({}, f,
383
+ ties.has(f.table) ? { authTied: ties.get(f.table) } : {},
384
+ owned.has(f.table) ? { owned: owned.get(f.table) } : {});
385
+
375
386
  return {
376
387
  stopped: null,
377
388
  attacksRun: attempted.length,
378
389
  notChecked: notChecked,
379
390
  attempted: attempted,
380
391
  findings: finding.describeAll(
381
- impersonation.findings.concat(writes.findings, stranded.findings, raced, privileged),
392
+ impersonation.findings.concat(writes.findings, stranded.findings, raced, privileged).map(tagged),
382
393
  ),
383
394
  };
384
395
  } finally {
package/tamper.js CHANGED
@@ -131,7 +131,13 @@ async function tamper(client, schema, tables, seeded, policies) {
131
131
  // Written under a name nobody has used, which is both what makes the
132
132
  // row land at all and what makes it the right test: adding a row of
133
133
  // your own is the feature, adding one under somebody else name is not.
134
- const row = await attack.rowFor(client, schema, table, attack.USER_C, 7, null, shapeFor.get(table.name));
134
+ //
135
+ // Unless the owner is a key into one of the app's own tables - provider_id
136
+ // -> profiles.id - where only the two seeded people exist. Found on a
137
+ // blind test: the nameless insert hit the foreign key, and "any provider
138
+ // can add appointments for any other" came back as not tested. The other
139
+ // seeded person is still somebody else's name to the signed-in caller.
140
+ const row = await attack.rowFor(client, schema, table, writeAsFor(table, owner, tables), 7, null, shapeFor.get(table.name));
135
141
  const placeholders = row.values.map((_, i) => '$' + (i + 1));
136
142
  const attempts = [
137
143
  row.columns.length
@@ -186,6 +192,18 @@ async function tamper(client, schema, tables, seeded, policies) {
186
192
  return { findings: findings, completed: completed, blocked: blocked };
187
193
  }
188
194
 
195
+ /**
196
+ * Whose name an insert is written under: nobody's yet (USER_C), unless the
197
+ * owner column is a key into one of the app's own tables, where only the two
198
+ * seeded people exist - then the one who is not the signed-in caller.
199
+ */
200
+ function writeAsFor(table, owner, tables) {
201
+ if (!owner) return attack.USER_C;
202
+ const key = attack.foreignKeys(table).find((k) => k.columns.length === 1 && k.columns[0] === owner);
203
+ const inApp = key && (tables || []).some((t) => t.name === key.refTable);
204
+ return inApp ? attack.USER_A : attack.USER_C;
205
+ }
206
+
189
207
  /**
190
208
  * A column an UPDATE can harmlessly set to itself.
191
209
  *
@@ -204,5 +222,6 @@ module.exports = {
204
222
  tryWrite: tryWrite,
205
223
  whatHappened: whatHappened,
206
224
  firstWritable: firstWritable,
225
+ writeAsFor: writeAsFor,
207
226
  tamper: tamper,
208
227
  };