kryptheon-night 0.1.31 → 0.1.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/attack.js CHANGED
@@ -204,6 +204,132 @@ function allowedByCheck(table, columnName) {
204
204
  return found;
205
205
  }
206
206
 
207
+ /**
208
+ * The CHECK constraints on a table that name this column, as the expression
209
+ * inside CHECK (...). A NOT VALID one still applies to every new row.
210
+ */
211
+ function checksOn(table, columnName) {
212
+ const escaped = String(columnName).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
213
+ const named = new RegExp('(^|[^A-Za-z0-9_$"])("' + escaped.split('"').join('""') + '"|' + escaped + ')($|[^A-Za-z0-9_$"])');
214
+ const found = [];
215
+ for (const constraint of table.constraints || []) {
216
+ if (constraint.kind !== 'c') continue;
217
+ const definition = String(constraint.definition || '');
218
+ if (!named.test(definition)) continue;
219
+ found.push(definition.replace(/^CHECK\s*/i, '').replace(/\s+NOT VALID\s*$/i, ''));
220
+ }
221
+ return found;
222
+ }
223
+
224
+ const NUMERIC_KIND = /^(integer|bigint|smallint|numeric|decimal|real|double)/;
225
+ const TEXT_KIND = /^(text|character|varchar|citext)/;
226
+
227
+ /**
228
+ * Values to offer a column whose CHECK could not be read, best first.
229
+ *
230
+ * Found on a real app (a clinic's, 2026-10-11): patient_waitlist and
231
+ * whatsapp_contacts each had a phone number column with a CHECK on its
232
+ * format, compliance_requests a type column with a CHECK no reading here
233
+ * understood - and none of the made-up values fitted, so three tables, and a
234
+ * fourth that pointed at one of them, were never tested. Nothing here reads
235
+ * the rule. Each value is put to the rule itself, in the database
236
+ * (fittingValue), and the first it accepts is used.
237
+ *
238
+ * Mirrored by __KN__.check_candidates in engine.sql, value for value and in
239
+ * the same order: both engines must seed the same rows.
240
+ */
241
+ function checkCandidates(column, definitions, distinct) {
242
+ const kind = String(column.base_type || column.type).toLowerCase();
243
+ const tag = distinct === undefined || distinct === null ? '' : String(distinct);
244
+ const step = Number(tag) || 0;
245
+ const out = [];
246
+ const add = (value) => { if (out.indexOf(value) === -1) out.push(value); };
247
+ if (NUMERIC_KIND.test(kind)) {
248
+ [1 + step, 18 + step, 100 + step, 1000 + step, 0, 5].forEach((n) => add(String(n)));
249
+ for (const definition of definitions) {
250
+ const numbers = definition.match(/-?\d+(?:\.\d+)?/g) || [];
251
+ numbers.forEach(add);
252
+ }
253
+ return out;
254
+ }
255
+ if (!TEXT_KIND.test(kind)) return out;
256
+ const d = String(step % 10);
257
+ const phones = ['+1415555012' + d, '+919876543' + (210 + step), '9876543' + (210 + step), '415555012' + d,
258
+ '+1 415-555-012' + d, '(415) 555-012' + d, '415-555-012' + d, '+44 7700 90012' + d, '07700 90012' + d];
259
+ const emails = ['kryptheon' + tag + '@example.com'];
260
+ const links = ['https://example.com/kryptheon' + tag];
261
+ // The shapes valueFor offers, in its order, so a rule they already passed
262
+ // still gets the value it always got.
263
+ const words = [tag ? tag + ' kryptheon test' : 'kryptheon test', 'kryptheon' + tag + '@example.com', 'KN' + (tag || '1'),
264
+ String(step + 1), 'https://example.com/kryptheon', 'kryptheon' + tag, 'Kryptheon' + tag, 'kn-' + (tag || '1')];
265
+ const literals = [];
266
+ for (const definition of definitions) {
267
+ const quoted = /'((?:[^']|'')*)'/g;
268
+ let one;
269
+ while ((one = quoted.exec(definition)) !== null) literals.push(one[1].split("''").join("'"));
270
+ }
271
+ const codes = ['US', 'IN', 'USD', 'INR', 'EUR', 'en', 'en-US', '#00000' + d, '2026-01-0' + (1 + (step % 9)), '1234' + d, '56000' + d,
272
+ 'kryptheon test value long enough for a rule on a minimum length ' + (tag || '0')];
273
+ const name = String(column.name).toLowerCase();
274
+ const first = /phone|mobile|whatsapp|tel|msisdn/.test(name) ? phones
275
+ : /mail/.test(name) ? emails
276
+ : /url|link|website|site|href/.test(name) ? links : [];
277
+ for (const value of first.concat(words, literals, phones, emails, links, codes)) add(fitTo(value, kind));
278
+ return out;
279
+ }
280
+
281
+ /**
282
+ * The first values a column's CHECK constraints accept, asked of the
283
+ * database itself: each rule is run on the value alone, the column given as
284
+ * the value by a one-row subquery. A rule that also names another column
285
+ * cannot be run alone and is left out; when no rule can be, nothing is
286
+ * offered and the seeder goes on as before. Remembered per table, column and
287
+ * row, so the attacks that insert later ask nothing again.
288
+ *
289
+ * Returns the accepted values, best first - possibly none.
290
+ */
291
+ const FITTED = new WeakMap();
292
+ async function fittingValues(client, table, column, distinct) {
293
+ const definitions = checksOn(table, column.name);
294
+ if (!definitions.length) return [];
295
+ const candidates = checkCandidates(column, definitions, distinct);
296
+ if (!candidates.length) return [];
297
+ let byTable = FITTED.get(table);
298
+ if (!byTable) FITTED.set(table, (byTable = new Map()));
299
+ const key = column.name + '\u0000' + String(distinct);
300
+ if (byTable.has(key)) return byTable.get(key);
301
+
302
+ const type = String(column.type);
303
+ const alone = (expression, value) =>
304
+ 'SELECT (' + expression + ') IS NOT FALSE AS ok FROM (SELECT ' + value + '::' + type + ' AS ' + quote(column.name) + ') AS kn_row';
305
+ const runnable = [];
306
+ for (const expression of definitions) {
307
+ try {
308
+ await client.query(alone(expression, 'NULL'));
309
+ runnable.push(expression);
310
+ } catch (err) {
311
+ // Names another column, or something only the table can supply.
312
+ }
313
+ }
314
+ let accepted = [];
315
+ if (runnable.length) {
316
+ const all = runnable.map((e) => '(' + e + ') IS NOT FALSE').join(' AND ');
317
+ for (const value of candidates) {
318
+ try {
319
+ const { rows } = await client.query(
320
+ 'SELECT ' + all + ' AS ok FROM (SELECT $1::' + type + ' AS ' + quote(column.name) + ') AS kn_row', [value]);
321
+ if (rows[0] && rows[0].ok === true) accepted.push(value);
322
+ // As many as the seeder will ever try; asking further changes nothing.
323
+ if (accepted.length >= SHAPES_TO_TRY) break;
324
+ } catch (err) {
325
+ // A value its own type, or the rule, refuses outright.
326
+ }
327
+ }
328
+ }
329
+ byTable.set(key, accepted);
330
+ return accepted;
331
+ }
332
+
207
333
  /**
208
334
  * Sets of columns where exactly one may be filled in.
209
335
  *
@@ -500,11 +626,14 @@ async function rowFor(client, schema, table, person, distinct, overrides, attemp
500
626
  if (column.default_expr) continue;
501
627
  if (!column.not_null && !mustFill) continue;
502
628
  columns.push(column.name);
503
- // A CHECK that lists what it will accept beats anything invented here.
629
+ // A CHECK that lists what it will accept beats anything invented here;
630
+ // after that, a value one it could not read was asked about and accepts.
504
631
  const allowed = allowedByCheck(table, column.name);
632
+ const fitting = allowed.length ? [] : await fittingValues(client, table, column, distinct);
633
+ const pick = (list) => list[Math.min(Math.max(attempt || 0, 0), list.length - 1)];
505
634
  values.push(
506
- allowed.length
507
- ? allowed[Math.min(Math.max(attempt || 0, 0), allowed.length - 1)]
635
+ allowed.length ? pick(allowed)
636
+ : fitting.length ? pick(fitting)
508
637
  : valueFor(column, person, distinct, attempt),
509
638
  );
510
639
  }
@@ -531,6 +660,27 @@ function insertRow(client, schema, table, row, back) {
531
660
  );
532
661
  }
533
662
 
663
+ /**
664
+ * The column that says which organization's a row is, on a table with no
665
+ * person's name on it - or null. Its first key to a table that others point
666
+ * at (an organization, a workspace, a team), where the table itself is not one
667
+ * of those: seeding gives each person their own organization, and a table like
668
+ * this one row, in the first person's. Found on a blind test (HarborLine):
669
+ * integration_secrets(organization_id, access_token) was readable by every
670
+ * signed-in user, and with no person column it was never tried at all.
671
+ */
672
+ function tenantKey(table, tables, parents) {
673
+ // A view is not seeded: it shows whatever its tables hold, the second
674
+ // person's own organization included, so a row of it read by them proves
675
+ // nothing. Found on the same test: workspace_health, filtered to the
676
+ // organizations the reader belongs to, came back as a leak.
677
+ if (table.isView || parents.has(table.name)) return null;
678
+ for (const key of foreignKeys(table).concat(impliedKeys(table, tables))) {
679
+ if (key.refTable !== table.name && parents.has(key.refTable) && key.columns.length === 1) return key.columns[0];
680
+ }
681
+ return null;
682
+ }
683
+
534
684
  /** Every table some other table points at, by a key it declared or one it implies. */
535
685
  function parentTables(tables) {
536
686
  const parents = new Set();
@@ -619,7 +769,19 @@ async function seed(client, schema, tables) {
619
769
  // this table can use the same one instead of rediscovering it, and be
620
770
  // sure a refusal is the app defending itself rather than a CHECK it
621
771
  // never satisfied.
622
- seeded.push({ table: table.name, owner: owner, attempt: worked });
772
+ const entry = { table: table.name, owner: owner, attempt: worked };
773
+ // A table with nobody's name on it that got a row for each person - an
774
+ // organization's invoices, say - holds the second person's own row too.
775
+ // Which row is the first person's is kept, so a write meant for
776
+ // "somebody else's row" is aimed at it. Found on a blind test
777
+ // (HarborLine): an unscoped UPDATE changed the second person's own
778
+ // invoice, and was reported as any customer changing another's.
779
+ const firstRow = !owner && rows.get(table.name) && rows.get(table.name).get(USER_A);
780
+ const pk = primaryKeyOf(table);
781
+ if (firstRow && rows.get(table.name).size === 2 && pk && firstRow[pk] !== null && firstRow[pk] !== undefined) {
782
+ entry.rowOfA = { column: pk, value: String(firstRow[pk]) };
783
+ }
784
+ seeded.push(entry);
623
785
  } else {
624
786
  // Recorded, never swallowed. The report has to say this table was not
625
787
  // checked rather than let an empty table pass for a safe one.
@@ -747,8 +909,10 @@ async function impersonate(client, schema, tables) {
747
909
  return false;
748
910
  };
749
911
 
912
+ const parents = parentTables(tables);
750
913
  for (const table of tables) {
751
914
  const owner = ownerColumn(table);
915
+ const tenant = owner ? null : tenantKey(table, tables, parents);
752
916
  const anon = await readAs(client, schema, table.name, 'anon', null);
753
917
  const asA = await readAs(client, schema, table.name, 'authenticated', USER_A);
754
918
 
@@ -794,6 +958,27 @@ async function impersonate(client, schema, tables) {
794
958
  });
795
959
  }
796
960
  }
961
+
962
+ // A table whose rows are an organization's, not a person's: its one row is
963
+ // the first person's organization's, and the second person - signed in,
964
+ // in an organization of their own - asks for it. What a logged-out
965
+ // stranger can already read is reported as exposed, and not twice.
966
+ if (tenant) {
967
+ const asB = await readAs(client, schema, table.name, 'authenticated', USER_B);
968
+ const open = Array.isArray(anon) && anon.length > 0;
969
+ if (settle('crossed:' + table.name, table.name, asB, 'as a signed-in user in another organization') &&
970
+ asB.length > 0 && !open) {
971
+ findings.push({
972
+ kind: 'crossed',
973
+ table: table.name,
974
+ owner: tenant,
975
+ tenant: true,
976
+ readable: asB.length,
977
+ columns: Object.keys(asB[0] || {}),
978
+ rlsEnabled: table.rlsEnabled,
979
+ });
980
+ }
981
+ }
797
982
  }
798
983
 
799
984
  return { findings: findings, completed: completed, blocked: blocked, looped: looped };
@@ -826,6 +1011,9 @@ module.exports = {
826
1011
  dependencyOrder: dependencyOrder,
827
1012
  valueFor: valueFor,
828
1013
  allowedByCheck: allowedByCheck,
1014
+ checksOn: checksOn,
1015
+ checkCandidates: checkCandidates,
1016
+ fittingValues: fittingValues,
829
1017
  exactlyOne: exactlyOne,
830
1018
  rowFor: rowFor,
831
1019
  existingRow: existingRow,
package/collision.js CHANGED
@@ -214,7 +214,12 @@ async function collide(client, one, two, schema, tables, indexes) {
214
214
 
215
215
  for (const target of candidates(tables, indexes)) {
216
216
  const table = byName.get(target.table);
217
- const value = collidingValue(target);
217
+ // A column with a CHECK of its own - an email that has to look like one -
218
+ // refused the made-up value, and the race never started. A value the
219
+ // rule was asked about and accepts is raced instead.
220
+ const column = table && (table.columns || []).find((c) => c.name === target.column);
221
+ const fitting = column ? await attack.fittingValues(client, table, column, 'collision') : [];
222
+ const value = fitting.length ? fitting[0] : collidingValue(target);
218
223
  const override = {};
219
224
  override[target.column] = value;
220
225