kryptheon-night 0.1.2 → 0.1.3

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/schema.js +72 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon-night",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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/schema.js CHANGED
@@ -530,10 +530,23 @@ function qualifyOwnRefs(definition, plan, target) {
530
530
  // A replacer function, never a replacement string: `$&` and friends are read
531
531
  // as instructions inside one, and that has already cost this project an
532
532
  // engine that would not install.
533
+ // A quoted identifier can hold anything, including spaces and quotes of its
534
+ // own doubled up, so it is matched as a whole rather than as a run of safe
535
+ // characters. The first attempt used [^".\s(]+ inside the quotes and
536
+ // silently did not match `REFERENCES "Group Table"(...)` - which the twin
537
+ // fixture has, because it was built out of every shape that has ever been
538
+ // read wrong, and it caught this the first time it ran.
539
+ //
540
+ // The unquoted alternative excludes a dot, so a name that is already
541
+ // schema-qualified is left alone: that is the other rewrite's work.
533
542
  return String(definition).replace(
534
- /(\bREFERENCES\s+)("?)([^".\s(]+)\2(\s*\()/gi,
535
- (whole, before, quoteMark, name, after) =>
536
- (own.has(name) ? before + quote(target) + '.' + quote(name) + after : whole),
543
+ /(\bREFERENCES\s+)("(?:[^"]|"")*"|[^\s(".]+)(\s*\()/gi,
544
+ (whole, before, written, after) => {
545
+ const name = written.charAt(0) === '"'
546
+ ? written.slice(1, -1).split('""').join('"')
547
+ : written;
548
+ return own.has(name) ? before + quote(target) + '.' + quote(name) + after : whole;
549
+ },
537
550
  );
538
551
  }
539
552
 
@@ -809,8 +822,37 @@ async function writeSchema(client, plan, target) {
809
822
  }
810
823
 
811
824
  mustStayInside(statements, target);
812
- for (const statement of statements) {
813
- await client.query(statement);
825
+ // Built with the copy first on the search_path, so a bare name binds to the
826
+ // copy and not to whatever the customer happens to have.
827
+ //
828
+ // This is the structural half, and it went in after chasing the same bug
829
+ // through three different kinds of expression. Postgres writes a name
830
+ // without its schema whenever that name is already reachable - so a real app
831
+ // in `public` hands back "REFERENCES profiles(id)" and
832
+ // "nextval('orders_id_seq'::regclass)", and a rewrite that goes looking for
833
+ // a schema name finds nothing to change in either. Replayed into the copy,
834
+ // both bound to the customer's own objects: the foreign keys pointed at
835
+ // their tables, and the copy drew its keys from their sequences, which
836
+ // advanced them.
837
+ //
838
+ // Rewriting each kind of expression in turn is a game with no end - index
839
+ // predicates, checks that call a function, view bodies. Naming the copy
840
+ // first on the path ends all of them at once, because it changes what a
841
+ // bare name means rather than trying to find every place one can appear.
842
+ //
843
+ // `public` stays on the path, after the copy, because the copy legitimately
844
+ // needs what lives there - gen_random_uuid() and the like. `extensions` is
845
+ // where Supabase keeps them; a schema on the path that does not exist is
846
+ // ignored rather than an error.
847
+ const { rows: pathRows } = await client.query('SHOW search_path');
848
+ const restoreTo = pathRows[0].search_path;
849
+ await client.query('SET search_path TO ' + quote(target) + ', public, extensions');
850
+ try {
851
+ for (const statement of statements) {
852
+ await client.query(statement);
853
+ }
854
+ } finally {
855
+ await client.query('SET search_path TO ' + restoreTo).catch(() => {});
814
856
  }
815
857
 
816
858
  // And then look at what was actually built, rather than at what was meant.
@@ -825,20 +867,41 @@ async function writeSchema(client, plan, target) {
825
867
  // Nothing is written to a table outside the copy either way. The damage is
826
868
  // subtler than that: a copy tied to the customer's rows is not the app being
827
869
  // attacked, and every verdict taken from it is about something else.
870
+ // Foreign keys AND column defaults, because the second one is where this
871
+ // hid after the first was closed: a serial column's default is
872
+ // nextval('<sequence>'), and the copy was calling the customer's. Nothing
873
+ // was written to their tables, but nextval advances a sequence, so their
874
+ // database changed - and "we never touch your live app" says at all.
875
+ //
876
+ // Asked of pg_depend rather than of the text, so it holds for any
877
+ // expression that ends up pointing at a relation, not only the ones anybody
878
+ // thought to look at. pg_catalog is excluded because everything depends on
879
+ // it; anything else outside the copy is the bug.
828
880
  const { rows: strays } = await client.query(
829
- `SELECT con.conname, rn.nspname AS points_at
881
+ `SELECT con.conname AS what, rn.nspname AS points_at
830
882
  FROM pg_constraint con
831
883
  JOIN pg_class cl ON cl.oid = con.conrelid
832
884
  JOIN pg_namespace cn ON cn.oid = cl.relnamespace
833
885
  JOIN pg_class rc ON rc.oid = con.confrelid
834
886
  JOIN pg_namespace rn ON rn.oid = rc.relnamespace
835
- WHERE con.contype = 'f' AND cn.nspname = $1 AND rn.nspname <> $1`,
887
+ WHERE con.contype = 'f' AND cn.nspname = $1 AND rn.nspname <> $1
888
+ UNION ALL
889
+ SELECT cl.relname || '.' || a.attname || ' default' AS what,
890
+ rn.nspname || '.' || rc.relname AS points_at
891
+ FROM pg_depend d
892
+ JOIN pg_attrdef ad ON ad.oid = d.objid AND d.classid = 'pg_attrdef'::regclass
893
+ JOIN pg_class cl ON cl.oid = ad.adrelid
894
+ JOIN pg_namespace cn ON cn.oid = cl.relnamespace
895
+ JOIN pg_attribute a ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
896
+ JOIN pg_class rc ON rc.oid = d.refobjid AND d.refclassid = 'pg_class'::regclass
897
+ JOIN pg_namespace rn ON rn.oid = rc.relnamespace
898
+ WHERE cn.nspname = $1 AND rn.nspname <> $1 AND rn.nspname <> 'pg_catalog'`,
836
899
  [target],
837
900
  );
838
901
  if (strays.length) {
839
902
  throw new Error(
840
903
  'the copy points outside itself, so it is not the app: ' +
841
- strays.map((row) => row.conname + ' -> ' + row.points_at).join(', '),
904
+ strays.map((row) => row.what + ' -> ' + row.points_at).join(', '),
842
905
  );
843
906
  }
844
907
 
@@ -987,6 +1050,7 @@ module.exports = {
987
1050
  readExternalTargets: readExternalTargets,
988
1051
  referenceIn: referenceIn,
989
1052
  rewriteSchemaRefs: rewriteSchemaRefs,
1053
+ qualifyOwnRefs: qualifyOwnRefs,
990
1054
  stubNameFor: stubNameFor,
991
1055
  IDENTITIES: IDENTITIES,
992
1056
  quote: quote,