kryptheon-night 0.1.26 → 0.1.29

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/README.md CHANGED
@@ -74,7 +74,7 @@ whether they are meant:
74
74
 
75
75
  | check | the question |
76
76
  |---|---|
77
- | **Lowest role** | can a viewer in a team change what is in it? (tried on the copy, undone) |
77
+ | **Team roles** | which role in a team, from the lowest up, can change what its rules do not list it for, and which can read its tokens, keys and payloads? (tried on the copy, undone) |
78
78
  | **Storage** | is a bucket public although a rule says who may read it? (settings only, never a file) |
79
79
  | **Open functions** | can a visitor with no account call a function that skips the rules? |
80
80
 
package/attack.js CHANGED
@@ -204,6 +204,48 @@ function allowedByCheck(table, columnName) {
204
204
  return found;
205
205
  }
206
206
 
207
+ /**
208
+ * Sets of columns where exactly one may be filled in.
209
+ *
210
+ * Found on a blind test (HelixOps): documents belonged to a work order or to
211
+ * an inspection, never both, written
212
+ * CHECK ((work_order_id IS NOT NULL)::int + (inspection_id IS NOT NULL)::int = 1)
213
+ * Both parents were there, so the seeder pointed the row at both, the CHECK
214
+ * refused every attempt, and the table was never tested at all.
215
+ *
216
+ * Two spellings are read - that sum, and num_nonnulls(a, b, ...) = 1 - and
217
+ * only when the constraint says nothing else. A CHECK with anything more in it
218
+ * is left alone rather than half understood.
219
+ */
220
+ function exactlyOne(table) {
221
+ const groups = [];
222
+ const name = '("(?:[^"]|"")+"|[A-Za-z_][A-Za-z0-9_$]*)';
223
+ const unquote = (text) => (text.charAt(0) === '"' ? text.slice(1, -1).split('""').join('"') : text);
224
+ for (const constraint of table.constraints || []) {
225
+ if (constraint.kind !== 'c') continue;
226
+ const definition = String(constraint.definition || '');
227
+ let columns = [];
228
+ let rest = definition;
229
+ const summed = new RegExp('\\(\\(' + name + ' IS NOT NULL\\)\\)::integer', 'gi');
230
+ let match;
231
+ while ((match = summed.exec(definition)) !== null) columns.push(unquote(match[1]));
232
+ rest = rest.replace(summed, '').replace(/\+/g, '');
233
+ if (!columns.length) {
234
+ const counted = /num_nonnulls\(([^()]*)\)/i.exec(definition);
235
+ if (counted) {
236
+ const listed = new RegExp(name, 'g');
237
+ let one;
238
+ while ((one = listed.exec(counted[1])) !== null) columns.push(unquote(one[1]));
239
+ rest = rest.replace(counted[0], '');
240
+ }
241
+ }
242
+ // Nothing may be left but the CHECK itself, brackets and "= 1".
243
+ if (columns.length < 2 || rest.replace(/[()\s]/g, '').toUpperCase() !== 'CHECK=1') continue;
244
+ groups.push(columns);
245
+ }
246
+ return groups;
247
+ }
248
+
207
249
  /**
208
250
  * The foreign keys on a table, read out of Postgres's own wording.
209
251
  *
@@ -395,12 +437,28 @@ async function rowFor(client, schema, table, person, distinct, overrides, attemp
395
437
  const partOfKey = new Set();
396
438
  for (const key of foreignKeys(table)) key.columns.forEach((name) => partOfKey.add(name));
397
439
 
440
+ // Where exactly one of a set may be filled in: the one already chosen, or
441
+ // else the first that can be - a key needs a parent row to point at. The
442
+ // rest are written as nothing, whatever their default.
443
+ const chosen = (name) => Object.prototype.hasOwnProperty.call(forced, name);
444
+ const onlyOne = new Map();
445
+ for (const group of exactlyOne(table)) {
446
+ const kept = group.find(chosen) || group.find((name) => !partOfKey.has(name) || borrowed.has(name));
447
+ for (const name of group) onlyOne.set(name, name === kept);
448
+ }
449
+
398
450
  for (const column of table.columns) {
399
451
  if (Object.prototype.hasOwnProperty.call(forced, column.name)) {
400
452
  columns.push(column.name);
401
453
  values.push(forced[column.name]);
402
454
  continue;
403
455
  }
456
+ if (onlyOne.get(column.name) === false) {
457
+ columns.push(column.name);
458
+ values.push(null);
459
+ continue;
460
+ }
461
+ const mustFill = onlyOne.get(column.name) === true;
404
462
  if (column.name === owner) {
405
463
  columns.push(column.name);
406
464
  values.push(person);
@@ -436,9 +494,11 @@ async function rowFor(client, schema, table, person, distinct, overrides, attemp
436
494
  values.push(borrowed.get(column.name));
437
495
  continue;
438
496
  }
439
- // Anything with a default can supply its own value.
497
+ // Anything with a default can supply its own value. A column that may be
498
+ // empty is left empty - unless it is the one of a set that has to be
499
+ // filled in.
440
500
  if (column.default_expr) continue;
441
- if (!column.not_null) continue;
501
+ if (!column.not_null && !mustFill) continue;
442
502
  columns.push(column.name);
443
503
  // A CHECK that lists what it will accept beats anything invented here.
444
504
  const allowed = allowedByCheck(table, column.name);
@@ -766,6 +826,7 @@ module.exports = {
766
826
  dependencyOrder: dependencyOrder,
767
827
  valueFor: valueFor,
768
828
  allowedByCheck: allowedByCheck,
829
+ exactlyOne: exactlyOne,
769
830
  rowFor: rowFor,
770
831
  existingRow: existingRow,
771
832
  insertRow: insertRow,
@@ -648,7 +648,9 @@ async function main() {
648
648
  if (result.findings.length) scanner.report(result);
649
649
  else scanner.notTestedLines(result).forEach(line);
650
650
  scanner.saveRun(file, result);
651
- process.exitCode = result.stopped ? 2 : verdict.allClear ? 0 : 1;
651
+ // Two different databases: the verdict is a comparison, so the exit
652
+ // code is this run's own answer, the same as a first run.
653
+ process.exitCode = result.stopped ? 2 : verdict.elsewhere ? scanner.exitCodeFor(result) : verdict.allClear ? 0 : 1;
652
654
  }
653
655
 
654
656
  // And then the only other thing worth doing, offered rather than
package/collision.js CHANGED
@@ -69,6 +69,20 @@ const MUST_BE_UNIQUE = [
69
69
  */
70
70
  const ACCOUNT_TABLE = /^(users?|profiles?|accounts?|members?|auth_users|app_users|logins?)$/i;
71
71
 
72
+ /**
73
+ * Tables that keep another service's credentials, so this app can call it.
74
+ *
75
+ * A token there was issued by somebody else and opens nothing in this app, so
76
+ * two rows sharing one is not a way in. Found on a blind test (LaunchRail):
77
+ * integration_credentials.access_token and refresh_token were reported as
78
+ * "can exist twice" - not in the answer key, and not a door into the app.
79
+ * Known by the table's name, or by a `provider` column naming the service.
80
+ */
81
+ const OUTBOUND_TABLE = /(^|_)(integrations?|providers?|oauth|connections?|connectors?|external|third_party)(_|$)/i;
82
+ function holdsOutboundCredentials(table) {
83
+ return OUTBOUND_TABLE.test(table.name) || (table.columns || []).some((c) => /^provider$/i.test(c.name));
84
+ }
85
+
72
86
  /** Everything on a table that already promises "only one of these". */
73
87
  function uniquenessTexts(table, indexes) {
74
88
  const fromConstraints = (table.constraints || [])
@@ -118,6 +132,7 @@ function candidates(tables, indexes) {
118
132
  const rule = MUST_BE_UNIQUE.find((r) => r.test.test(column.name));
119
133
  if (!rule) continue;
120
134
  if (rule.accountTablesOnly && !ACCOUNT_TABLE.test(table.name)) continue;
135
+ if (rule.expectation === 'credential' && holdsOutboundCredentials(table)) continue;
121
136
  found.push({
122
137
  table: table.name,
123
138
  column: column.name,