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 +192 -4
- package/collision.js +6 -1
- package/engine.sql +440 -10
- package/finding.js +105 -11
- package/package.json +6 -3
- package/scan.js +17 -3
- package/schema.js +54 -12
- package/tamper.js +30 -5
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|