kryptheon-night 0.1.0
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 +158 -0
- package/attack.js +555 -0
- package/bin/kryptheon-night.js +248 -0
- package/collision.js +309 -0
- package/connect.js +60 -0
- package/finding.js +552 -0
- package/intro.js +202 -0
- package/orphan.js +230 -0
- package/package.json +73 -0
- package/recheck.js +219 -0
- package/scan.js +569 -0
- package/schema.js +922 -0
- package/tamper.js +208 -0
- package/trouble.js +377 -0
package/finding.js
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
// Turning what the attack saw into something a person can act on.
|
|
2
|
+
//
|
|
3
|
+
// The detection is the easy tenth. A builder who cannot read code does not buy
|
|
4
|
+
// "crossed:customers:1" - they buy knowing their customer list is open, seeing
|
|
5
|
+
// it happen, and having one thing to paste that closes it.
|
|
6
|
+
//
|
|
7
|
+
// The rule everything here follows: never say more than was actually seen. If
|
|
8
|
+
// the attack read a table with an email column in it, say email. If it did not,
|
|
9
|
+
// do not reach for "personal details" because it sounds more urgent. A report
|
|
10
|
+
// that overstates once is a report nobody trusts again, and trust is the entire
|
|
11
|
+
// product - the re-check at the end is worth exactly as much as the first
|
|
12
|
+
// report was honest.
|
|
13
|
+
|
|
14
|
+
/* --------------------------------------------------------------------------
|
|
15
|
+
What was in the table.
|
|
16
|
+
-------------------------------------------------------------------------- */
|
|
17
|
+
|
|
18
|
+
// Things that identify a person. Matched on whole words so that `company_name`
|
|
19
|
+
// counts and `renamed_at` does not.
|
|
20
|
+
const IDENTITY = [
|
|
21
|
+
[/\b(email|e_mail|mail)\b/i, 'email addresses'],
|
|
22
|
+
[/\b(phone|mobile|contact_number|telephone)\b/i, 'phone numbers'],
|
|
23
|
+
[/\b(full_name|first_name|last_name|given_name|surname|name)\b/i, 'names'],
|
|
24
|
+
[/\b(address|street|city|postcode|zip|pincode)\b/i, 'addresses'],
|
|
25
|
+
[/\b(dob|date_of_birth|birth_date|birthday)\b/i, 'dates of birth'],
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// Things that are worse than identifying - they let somebody act as the person,
|
|
29
|
+
// or take money.
|
|
30
|
+
const SECRETS = [
|
|
31
|
+
[/\b(password|passwd|pass_hash|password_hash)\b/i, 'passwords'],
|
|
32
|
+
[/\b(token|api_key|apikey|secret|private_key|access_key)\b/i, 'access tokens'],
|
|
33
|
+
[/\b(card|card_number|cvv|iban|account_number|upi)\b/i, 'payment details'],
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const MONEY = [/\b(amount|total|price|balance|salary|revenue|invoice)\b/i, 'amounts of money'];
|
|
37
|
+
|
|
38
|
+
function matched(columns, table) {
|
|
39
|
+
const names = columns || [];
|
|
40
|
+
const found = [];
|
|
41
|
+
for (const [pattern, label] of table) {
|
|
42
|
+
if (names.some((column) => pattern.test(String(column)))) found.push(label);
|
|
43
|
+
}
|
|
44
|
+
return found;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What a table holds, in the words a person would use. */
|
|
48
|
+
function readContents(columns) {
|
|
49
|
+
const identity = matched(columns, IDENTITY);
|
|
50
|
+
const secrets = matched(columns, SECRETS);
|
|
51
|
+
const money = matched(columns, [MONEY]);
|
|
52
|
+
return { identity: identity, secrets: secrets, money: money };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A list, written the way a person writes one. */
|
|
56
|
+
function listOf(items) {
|
|
57
|
+
const list = items.filter(Boolean);
|
|
58
|
+
if (!list.length) return '';
|
|
59
|
+
if (list.length === 1) return list[0];
|
|
60
|
+
return list.slice(0, -1).join(', ') + ' and ' + list[list.length - 1];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* --------------------------------------------------------------------------
|
|
64
|
+
How bad it is.
|
|
65
|
+
-------------------------------------------------------------------------- */
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Severity, decided by what could be read rather than by how it was read.
|
|
69
|
+
*
|
|
70
|
+
* Only two levels on purpose. A scale of five invites the reader to skim past
|
|
71
|
+
* the bottom three, and everything the night shift reports is something that
|
|
72
|
+
* should be fixed - there is no such thing as a low finding here.
|
|
73
|
+
*/
|
|
74
|
+
function severityOf(finding, contents) {
|
|
75
|
+
if (finding.kind === 'orphaned') {
|
|
76
|
+
// Nothing is exposed and nothing is destroyed, so this is not the same
|
|
77
|
+
// order of thing as a table anyone can empty. It stays serious because
|
|
78
|
+
// the data it strands is data somebody asked to have deleted.
|
|
79
|
+
return 'HIGH';
|
|
80
|
+
}
|
|
81
|
+
if (finding.kind === 'writable') {
|
|
82
|
+
// Deleting and rewriting are unrecoverable in a way that reading is not:
|
|
83
|
+
// a leak is bad, but a customer table somebody emptied is gone. Being able
|
|
84
|
+
// only to add rows is serious and not the same thing.
|
|
85
|
+
const canDestroy = (finding.can || []).some((what) => what === 'delete' || what === 'change');
|
|
86
|
+
return canDestroy ? 'CRITICAL' : 'HIGH';
|
|
87
|
+
}
|
|
88
|
+
if (finding.kind === 'duplicated') {
|
|
89
|
+
// Judged by what the duplicate lets somebody do, not by what else sits in
|
|
90
|
+
// the table. A second row holding the same session token is critical even
|
|
91
|
+
// if the table has nothing personal in it at all.
|
|
92
|
+
if (finding.expectation === 'credential') return 'CRITICAL';
|
|
93
|
+
if (finding.expectation === 'identity') return 'CRITICAL';
|
|
94
|
+
return 'HIGH';
|
|
95
|
+
}
|
|
96
|
+
if (contents.secrets.length) return 'CRITICAL';
|
|
97
|
+
if (contents.identity.length) return 'CRITICAL';
|
|
98
|
+
return 'HIGH';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* --------------------------------------------------------------------------
|
|
102
|
+
Saying it.
|
|
103
|
+
-------------------------------------------------------------------------- */
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Why the door was open - and these two are genuinely different problems.
|
|
107
|
+
*
|
|
108
|
+
* Row level security switched off is an oversight. Switched on with a policy
|
|
109
|
+
* that lets everybody through is worse, because the dashboard shows the table
|
|
110
|
+
* as protected and the person has already been told they are safe. Saying which
|
|
111
|
+
* one it is saves them looking in the wrong place.
|
|
112
|
+
*/
|
|
113
|
+
function causeOf(finding) {
|
|
114
|
+
if (finding.kind === 'orphaned') {
|
|
115
|
+
return {
|
|
116
|
+
short: 'nothing in the database ties the two tables together',
|
|
117
|
+
long:
|
|
118
|
+
'There is no foreign key between these two tables, so the database has '+
|
|
119
|
+
'no idea they are related. It will not refuse a row that points at '+
|
|
120
|
+
'nothing, it will not stop somebody being deleted while their rows are '+
|
|
121
|
+
'still here, and it will never find these rows again afterwards.',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (finding.kind === 'writable') {
|
|
125
|
+
if (finding.rlsEnabled) {
|
|
126
|
+
// Which rule let it through, from what is actually on the table rather
|
|
127
|
+
// than from an assumption.
|
|
128
|
+
//
|
|
129
|
+
// This branch used to say, of every writable table with row level
|
|
130
|
+
// security on, that its rule "covers every command rather than only
|
|
131
|
+
// reading". Measured against a table whose only policy was written FOR
|
|
132
|
+
// INSERT, that sentence was false twice over: the rule covered one
|
|
133
|
+
// command, and there was no rule letting anybody read at all. The
|
|
134
|
+
// finding was proved; the reason was invented. A report that overstates
|
|
135
|
+
// once is believed never again, so the reason is now read, not guessed.
|
|
136
|
+
const rules = (finding.rules || []).map((rule) => String(rule).toUpperCase());
|
|
137
|
+
const WRITTEN_FOR = { add: 'INSERT', change: 'UPDATE', 'delete': 'DELETE' };
|
|
138
|
+
const IN_WORDS = { INSERT: 'adding rows', UPDATE: 'changing rows', DELETE: 'deleting rows' };
|
|
139
|
+
const named = [];
|
|
140
|
+
for (const move of finding.can || []) {
|
|
141
|
+
const command = WRITTEN_FOR[move];
|
|
142
|
+
if (command && rules.includes(command) && !named.includes(command)) named.push(command);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (rules.includes('ALL')) {
|
|
146
|
+
return {
|
|
147
|
+
short: 'the rule on it covers every command, not only reading',
|
|
148
|
+
long:
|
|
149
|
+
'The table has row level security switched on, but the rule attached ' +
|
|
150
|
+
'to it is written FOR ALL - so the same rule that decides who may ' +
|
|
151
|
+
'see a row also decides who may add, change and delete one, and it ' +
|
|
152
|
+
'is letting this through.',
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (named.length) {
|
|
157
|
+
return {
|
|
158
|
+
short: 'the rule you wrote for ' + listOf(named.map((c) => IN_WORDS[c])) +
|
|
159
|
+
' lets everybody through',
|
|
160
|
+
long:
|
|
161
|
+
'The table has row level security switched on, and there is a rule on ' +
|
|
162
|
+
'it written for ' + listOf(named.map((c) => IN_WORDS[c])) + '. That ' +
|
|
163
|
+
'rule is the one letting this through: it does not check who is ' +
|
|
164
|
+
'asking, so it accepts the request from a stranger exactly as it ' +
|
|
165
|
+
'would from the person the row belongs to.',
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Row level security on, and no permissive rule that names this write.
|
|
170
|
+
// It should not have been possible, and saying which rule did it would
|
|
171
|
+
// be inventing one. What is certain is what was done, and that is all
|
|
172
|
+
// this says.
|
|
173
|
+
return {
|
|
174
|
+
short: 'row level security is on, and something let this through anyway',
|
|
175
|
+
long:
|
|
176
|
+
'The table has row level security switched on, and none of the rules on ' +
|
|
177
|
+
'it are written for this kind of write - so this should have been ' +
|
|
178
|
+
'refused and was not. Check whether the role your app connects as owns ' +
|
|
179
|
+
'the table or has BYPASSRLS, because either of those goes round every ' +
|
|
180
|
+
'rule you have written.',
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
short: 'row level security was never switched on',
|
|
185
|
+
long:
|
|
186
|
+
'Row level security has never been switched on for this table. Supabase ' +
|
|
187
|
+
'grants insert, update and delete on the public schema by default, and ' +
|
|
188
|
+
'row level security is what takes them back - so until it is on, those ' +
|
|
189
|
+
'permissions are simply in force.',
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
if (finding.isView) {
|
|
193
|
+
// The one people are caught by, because everything looks right. The table
|
|
194
|
+
// is protected, the policy is correct, the dashboard is green - and the
|
|
195
|
+
// view hands the rows out anyway.
|
|
196
|
+
return {
|
|
197
|
+
short: 'a view reads with its creator\'s rights, not the visitor\'s',
|
|
198
|
+
long:
|
|
199
|
+
'This is a view, and a view runs as whoever created it unless it is ' +
|
|
200
|
+
'told otherwise. So any row level security on the tables underneath is ' +
|
|
201
|
+
'checked against the creator, not against the person asking - and the ' +
|
|
202
|
+
'rules you wrote on those tables do not apply here at all.',
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
if (finding.kind === 'duplicated') {
|
|
206
|
+
return {
|
|
207
|
+
short: 'nothing in the database makes it unique',
|
|
208
|
+
long:
|
|
209
|
+
'There is no unique constraint and no unique index on this column, so ' +
|
|
210
|
+
'the database has no way to refuse the second one. Checking in your app ' +
|
|
211
|
+
'code before inserting does not close this: between the check and the ' +
|
|
212
|
+
'insert, the other request has already been accepted.',
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
if (finding.rlsEnabled) {
|
|
216
|
+
return {
|
|
217
|
+
short: 'the rule that guards it lets everybody through',
|
|
218
|
+
long:
|
|
219
|
+
'The table has row level security switched on, so it looks protected, ' +
|
|
220
|
+
'but the rule attached to it allows every request. That is why nothing ' +
|
|
221
|
+
'in your dashboard flags it.',
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
short: 'it has no protection switched on at all',
|
|
226
|
+
long:
|
|
227
|
+
'Row level security has never been switched on for this table, so every ' +
|
|
228
|
+
'rule you might have written elsewhere does not apply to it.',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** What a caller could do, written the way a person would say it. */
|
|
233
|
+
const WRITE_WORDS = { add: 'add rows to', change: 'change rows in', delete: 'delete rows from' };
|
|
234
|
+
|
|
235
|
+
function headlineFor(finding) {
|
|
236
|
+
if (finding.kind === 'orphaned') {
|
|
237
|
+
return 'Your ' + finding.table + ' table can point at a ' +
|
|
238
|
+
finding.parent.replace(/s$/, '') + ' that does not exist.';
|
|
239
|
+
}
|
|
240
|
+
if (finding.kind === 'writable') {
|
|
241
|
+
// Worst first, because the headline is often all that gets read.
|
|
242
|
+
const order = ['delete', 'change', 'add'];
|
|
243
|
+
const does = order.filter((what) => (finding.can || []).includes(what)).map((what) => WRITE_WORDS[what]);
|
|
244
|
+
return (finding.who === 'anyone' ? 'Anyone' : 'Any signed-in customer') + ' can ' +
|
|
245
|
+
listOf(does) + ' your ' + finding.table + ' table.';
|
|
246
|
+
}
|
|
247
|
+
if (finding.kind === 'duplicated') {
|
|
248
|
+
return 'Your ' + finding.table + ' table lets the same ' + finding.column + ' exist twice.';
|
|
249
|
+
}
|
|
250
|
+
if (finding.kind === 'exposed') {
|
|
251
|
+
return 'Your ' + finding.table + ' ' + (finding.isView ? 'view' : 'table') + ' can be read by anyone.';
|
|
252
|
+
}
|
|
253
|
+
// The table name goes in front of the sentence rather than inside it. Put
|
|
254
|
+
// inside, a table called `customers` produced "One customer can read another
|
|
255
|
+
// customer's customers", which is the kind of line that loses a reader.
|
|
256
|
+
return 'Your ' + finding.table + ' table lets one customer read another one\'s rows.';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** What a duplicate of this particular column actually costs the person. */
|
|
260
|
+
const COST_OF_A_DUPLICATE = {
|
|
261
|
+
credential: 'A credential that matches more than one row means one secret opens more than one account.',
|
|
262
|
+
identity: 'Two accounts can answer to the same login, and a password reset has to guess which one to send.',
|
|
263
|
+
code: 'A one-time code that can exist twice can be redeemed twice.',
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
function bodyFor(finding, contents) {
|
|
267
|
+
const holds = listOf(contents.secrets.concat(contents.identity, contents.money));
|
|
268
|
+
const rowWord = finding.readable === 1 ? 'row' : 'rows';
|
|
269
|
+
|
|
270
|
+
if (finding.kind === 'orphaned') {
|
|
271
|
+
// Said as the thing a person will actually meet: a customer asks to be
|
|
272
|
+
// deleted, and their rows are still here afterwards.
|
|
273
|
+
return (
|
|
274
|
+
'I added a row to ' + finding.table + ' naming a ' +
|
|
275
|
+
finding.parent.replace(/s$/, '') + " that is not in your " + finding.parent +
|
|
276
|
+
' table, and it was accepted. So when one of them is deleted, the rows here ' +
|
|
277
|
+
'stay behind pointing at nobody - and a signup or checkout that stops halfway ' +
|
|
278
|
+
'leaves the same thing. The row I added was undone straight away.'
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (finding.kind === 'writable') {
|
|
283
|
+
const holds = listOf(contents.secrets.concat(contents.identity, contents.money));
|
|
284
|
+
const who = finding.who === 'anyone'
|
|
285
|
+
? 'Without logging in and without an account, I '
|
|
286
|
+
: 'Signed in as one of your customers, I ';
|
|
287
|
+
const did = [];
|
|
288
|
+
if ((finding.can || []).includes('add')) did.push('added a row');
|
|
289
|
+
if ((finding.can || []).includes('change')) {
|
|
290
|
+
did.push('changed ' + (finding.changed.change || 1) + " of another customer's rows");
|
|
291
|
+
}
|
|
292
|
+
if ((finding.can || []).includes('delete')) {
|
|
293
|
+
did.push('deleted ' + (finding.changed.delete || 1) + " of another customer's rows");
|
|
294
|
+
}
|
|
295
|
+
// Said immediately, because "I deleted your rows" is a sentence that stops
|
|
296
|
+
// somebody reading, and they need the next line more than the first.
|
|
297
|
+
return who + listOf(did) + ' on a copy of your app' +
|
|
298
|
+
(holds ? ', in a table holding ' + holds : '') +
|
|
299
|
+
'. Every one of those was undone straight away - nothing on the copy was ' +
|
|
300
|
+
'kept, and your live app was never touched.';
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (finding.kind === 'duplicated') {
|
|
304
|
+
// Said as what was done, not as what it implies. Two connections, one
|
|
305
|
+
// moment, both accepted, and here is the count afterwards.
|
|
306
|
+
return (
|
|
307
|
+
'I opened two connections to a copy of your app and inserted the same ' +
|
|
308
|
+
finding.column + ' from both at the same moment. Both were accepted, so there are ' +
|
|
309
|
+
'now ' + finding.copies + ' rows holding the identical value. ' +
|
|
310
|
+
(COST_OF_A_DUPLICATE[finding.expectation] || '')
|
|
311
|
+
).trim();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (finding.kind === 'exposed') {
|
|
315
|
+
return (
|
|
316
|
+
'Anyone on the internet, without logging in and without an account, can read ' +
|
|
317
|
+
'this table. I did it myself just now and got back ' + finding.readable + ' ' + rowWord +
|
|
318
|
+
(holds ? ', including ' + holds : '') + '.'
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
return (
|
|
322
|
+
'Signed in as one customer, I asked for another customer\'s rows and got ' +
|
|
323
|
+
finding.readable + ' of them back' + (holds ? ', including ' + holds : '') +
|
|
324
|
+
'. Every customer you have can do this to every other customer.'
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The thing they paste.
|
|
330
|
+
*
|
|
331
|
+
* Written to the tool they already use, which means it has to carry its own
|
|
332
|
+
* context - the assistant on the other end has not seen any of this. It names
|
|
333
|
+
* the table, says what is wrong in terms of the code rather than the symptom,
|
|
334
|
+
* and asks for the same mistake to be swept up elsewhere, because it is almost
|
|
335
|
+
* never in only one place.
|
|
336
|
+
*/
|
|
337
|
+
/** Wraps one paragraph to a width that reads in a chat box and a terminal. */
|
|
338
|
+
function wrapTo(text, width) {
|
|
339
|
+
const words = String(text).split(/\s+/);
|
|
340
|
+
const out = [];
|
|
341
|
+
let current = '';
|
|
342
|
+
for (const word of words) {
|
|
343
|
+
if ((current + ' ' + word).trim().length > width) {
|
|
344
|
+
out.push(current.trim());
|
|
345
|
+
current = word;
|
|
346
|
+
} else {
|
|
347
|
+
current = (current + ' ' + word).trim();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (current) out.push(current.trim());
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function fixPromptFor(finding) {
|
|
355
|
+
const cause = causeOf(finding);
|
|
356
|
+
const owner = finding.owner ? '"' + finding.owner + '"' : 'the column that says who each row belongs to';
|
|
357
|
+
|
|
358
|
+
const lines = finding.kind === 'orphaned'
|
|
359
|
+
? [
|
|
360
|
+
'My app has a data problem.',
|
|
361
|
+
'',
|
|
362
|
+
'The "' + finding.table + '" table has a "' + finding.column +
|
|
363
|
+
'" column that is meant to point at "' + finding.parent + '".' +
|
|
364
|
+
' There is no foreign key between them, so the database does not know they ' +
|
|
365
|
+
'are related. I proved it by adding a row naming one that does not exist, ' +
|
|
366
|
+
'and it was accepted.',
|
|
367
|
+
'',
|
|
368
|
+
'Add a foreign key from "' + finding.table + '"."' + finding.column +
|
|
369
|
+
'" to "' + finding.parent + '"."' + finding.parentKey + '".',
|
|
370
|
+
'',
|
|
371
|
+
'Decide on purpose what should happen when the parent row is deleted: ON ' +
|
|
372
|
+
'DELETE CASCADE if the children should go too, ON DELETE SET NULL if they ' +
|
|
373
|
+
'should stay without an owner, or nothing at all if the delete should be ' +
|
|
374
|
+
'refused while children exist.',
|
|
375
|
+
'',
|
|
376
|
+
'There may already be rows pointing at nothing, and the constraint will not ' +
|
|
377
|
+
'be created until those are dealt with. Find them first.',
|
|
378
|
+
]
|
|
379
|
+
: finding.kind === 'writable'
|
|
380
|
+
? [
|
|
381
|
+
'My app has a security problem.',
|
|
382
|
+
'',
|
|
383
|
+
'The "' + finding.table + '" table can be written to by ' +
|
|
384
|
+
(finding.who === 'anyone' ? 'anyone who is not logged in' : 'any signed-in user, including other users rows') +
|
|
385
|
+
', because ' + cause.short + '. I proved it: I ' +
|
|
386
|
+
(finding.can || []).map((what) => ({ add: 'added a row', change: 'changed rows', delete: 'deleted rows' })[what])
|
|
387
|
+
.join(', ') + '.',
|
|
388
|
+
'',
|
|
389
|
+
'Switch row level security on for this table, then write separate rules for ' +
|
|
390
|
+
'reading, inserting, updating and deleting. A rule written FOR SELECT does ' +
|
|
391
|
+
'not cover writes, and a rule written FOR ALL covers far more than reading.',
|
|
392
|
+
'',
|
|
393
|
+
'For insert and update, use WITH CHECK comparing ' + owner +
|
|
394
|
+
' to the id of the signed-in user, so nobody can write a row under somebody ' +
|
|
395
|
+
"else's name.",
|
|
396
|
+
'',
|
|
397
|
+
'Then check every other table for the same thing - a table with no row level ' +
|
|
398
|
+
'security on it is writable by default.',
|
|
399
|
+
]
|
|
400
|
+
: finding.kind === 'duplicated'
|
|
401
|
+
? [
|
|
402
|
+
'My app has a security problem.',
|
|
403
|
+
'',
|
|
404
|
+
'Two rows in the "' + finding.table + '" table can hold the same "' + finding.column +
|
|
405
|
+
'". I proved it by inserting the same value from two connections at the same ' +
|
|
406
|
+
'moment, and both were accepted.',
|
|
407
|
+
'',
|
|
408
|
+
'Add a unique constraint on "' + finding.table + '"."' + finding.column +
|
|
409
|
+
'" in the database itself. Checking in application code before inserting is not ' +
|
|
410
|
+
'enough - between the check and the insert, the other request has already gone in.',
|
|
411
|
+
'',
|
|
412
|
+
// Said because the obvious fix is wrong for multi-tenant apps, and being
|
|
413
|
+
// told to drop a legitimate design would cost them more than the bug.
|
|
414
|
+
'If the same value is allowed to repeat for different owners, make the constraint ' +
|
|
415
|
+
'cover both columns together rather than leaving it off.',
|
|
416
|
+
'',
|
|
417
|
+
'Then look for the same missing constraint on every other table and fix those too.',
|
|
418
|
+
]
|
|
419
|
+
: finding.isView
|
|
420
|
+
? [
|
|
421
|
+
'My app has a security problem.',
|
|
422
|
+
'',
|
|
423
|
+
'The "' + finding.table + '" view can be read by anyone who is not logged in. ' +
|
|
424
|
+
'A view runs with the rights of whoever created it, so the row level security ' +
|
|
425
|
+
'on the tables underneath is never checked against the person asking.',
|
|
426
|
+
'',
|
|
427
|
+
'Fix it by recreating the view with security_invoker set on, so it runs as the ' +
|
|
428
|
+
'visitor and the rules on the underlying tables apply - and make sure those ' +
|
|
429
|
+
'tables actually have those rules.',
|
|
430
|
+
'',
|
|
431
|
+
'Then check every other view in the app for the same thing.',
|
|
432
|
+
]
|
|
433
|
+
: [
|
|
434
|
+
'My app has a security problem.',
|
|
435
|
+
'',
|
|
436
|
+
'The "' + finding.table + '" table ' +
|
|
437
|
+
(finding.kind === 'exposed'
|
|
438
|
+
? 'can be read by anyone who is not logged in, because ' + cause.short + '.'
|
|
439
|
+
: 'lets one signed-in user read rows belonging to a different user, because ' + cause.short + '.'),
|
|
440
|
+
'',
|
|
441
|
+
'Fix it so a person can only read their own rows: compare ' + owner +
|
|
442
|
+
' against the id of the signed-in user, and make sure logged-out visitors get nothing.',
|
|
443
|
+
'',
|
|
444
|
+
'Then look for the same mistake on every other table and fix those too.',
|
|
445
|
+
];
|
|
446
|
+
// Wrapped here rather than by whoever prints it: this text is pasted into a
|
|
447
|
+
// chat box as often as it is read in a terminal, and an unwrapped paragraph
|
|
448
|
+
// is a wall in both.
|
|
449
|
+
return lines
|
|
450
|
+
.map((paragraph) => (paragraph ? wrapTo(paragraph, 72) : ['']))
|
|
451
|
+
.reduce((all, part) => all.concat(part), [])
|
|
452
|
+
.join('\n');
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Everything the report needs about one thing the attack found. */
|
|
456
|
+
function describe(finding) {
|
|
457
|
+
const contents = readContents(finding.columns);
|
|
458
|
+
const cause = causeOf(finding);
|
|
459
|
+
return {
|
|
460
|
+
severity: severityOf(finding, contents),
|
|
461
|
+
table: finding.table,
|
|
462
|
+
kind: finding.kind,
|
|
463
|
+
// Carried through because a table can have two different columns that each
|
|
464
|
+
// accept a duplicate, and the re-check tells one finding from another by
|
|
465
|
+
// what it is about. Without the column they would share an identity and
|
|
466
|
+
// fixing one would look like fixing both.
|
|
467
|
+
column: finding.column,
|
|
468
|
+
parent: finding.parent,
|
|
469
|
+
parentKey: finding.parentKey,
|
|
470
|
+
expectation: finding.expectation,
|
|
471
|
+
headline: headlineFor(finding, contents),
|
|
472
|
+
body: bodyFor(finding, contents),
|
|
473
|
+
cause: cause.long,
|
|
474
|
+
who: finding.who,
|
|
475
|
+
can: finding.can,
|
|
476
|
+
proof: finding.kind === 'duplicated'
|
|
477
|
+
? 'I created ' + finding.copies + ' rows in "' + finding.table + '" holding the same ' +
|
|
478
|
+
finding.column + '.'
|
|
479
|
+
: 'I read ' + finding.readable + ' ' + (finding.readable === 1 ? 'row' : 'rows') +
|
|
480
|
+
' from "' + finding.table + '" that should not have been readable.',
|
|
481
|
+
fixPrompt: fixPromptFor(finding),
|
|
482
|
+
contents: contents,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** The whole report, in the order a person should read it. */
|
|
487
|
+
function describeAll(findings) {
|
|
488
|
+
// A table anyone can read is also, necessarily, a table one customer can read
|
|
489
|
+
// of another. Reporting both puts the same table on screen twice and turns
|
|
490
|
+
// two problems into a list of three that reads like a mistake. The public one
|
|
491
|
+
// is kept, and it carries the rest.
|
|
492
|
+
const raw = findings || [];
|
|
493
|
+
const publiclyOpen = new Set(raw.filter((f) => f.kind === 'exposed').map((f) => f.table));
|
|
494
|
+
const described = raw
|
|
495
|
+
.filter((f) => !(f.kind === 'crossed' && publiclyOpen.has(f.table)))
|
|
496
|
+
.map((f) =>
|
|
497
|
+
Object.assign(describe(f), {
|
|
498
|
+
alsoCrossed:
|
|
499
|
+
f.kind === 'exposed' && raw.some((o) => o.kind === 'crossed' && o.table === f.table),
|
|
500
|
+
}),
|
|
501
|
+
);
|
|
502
|
+
for (const item of described) {
|
|
503
|
+
if (item.alsoCrossed) {
|
|
504
|
+
item.body += ' Your signed-in customers can read each other\'s rows for the same reason.';
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
// Worst first, and within that the ones open to the whole internet before the
|
|
508
|
+
// ones that need an account, and those before the ones that need two requests
|
|
509
|
+
// to arrive together.
|
|
510
|
+
// Writes above reads: a table somebody emptied is worse than one they read.
|
|
511
|
+
const byKind = { writable: 0, exposed: 1, crossed: 2, duplicated: 3, orphaned: 4 };
|
|
512
|
+
const rank = (d) => (d.severity === 'CRITICAL' ? 0 : 1) * 10 + (byKind[d.kind] === undefined ? 9 : byKind[d.kind]);
|
|
513
|
+
return described.sort((a, b) => rank(a) - rank(b));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** What is printed when a run finds nothing. Silence would read as a failure. */
|
|
517
|
+
function allClearLines(attacksRun) {
|
|
518
|
+
// Nothing attacked is not the same as nothing got through. The callers all
|
|
519
|
+
// stop before this now, but the sentence is the one thing in the product
|
|
520
|
+
// that must never be printed by accident, so it guards itself too.
|
|
521
|
+
if (!attacksRun) {
|
|
522
|
+
return ['', ' I did not manage to attack anything, so there is nothing to report.', ''];
|
|
523
|
+
}
|
|
524
|
+
// "Your data held" used to be the last line here, and it was the wrong
|
|
525
|
+
// sentence: it says something about the app, and all this program knows is
|
|
526
|
+
// something about the attacks it happened to run. A person who reads "your
|
|
527
|
+
// data held" stops looking. A person who reads "these attacks lost" knows
|
|
528
|
+
// what they have been handed and what they have not.
|
|
529
|
+
return [
|
|
530
|
+
'',
|
|
531
|
+
' Nothing got through.',
|
|
532
|
+
'',
|
|
533
|
+
' I ran ' + attacksRun + ' ' + (attacksRun === 1 ? 'attack' : 'attacks') + ' against a copy of your app,',
|
|
534
|
+
' and every one of them lost.',
|
|
535
|
+
'',
|
|
536
|
+
' That is not the same as "your app is safe". It means these attacks,',
|
|
537
|
+
' against this shape of database, this time, did not get in. Anything I',
|
|
538
|
+
' did not try is listed at the end.',
|
|
539
|
+
'',
|
|
540
|
+
];
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
module.exports = {
|
|
544
|
+
readContents: readContents,
|
|
545
|
+
severityOf: severityOf,
|
|
546
|
+
causeOf: causeOf,
|
|
547
|
+
describe: describe,
|
|
548
|
+
describeAll: describeAll,
|
|
549
|
+
fixPromptFor: fixPromptFor,
|
|
550
|
+
allClearLines: allClearLines,
|
|
551
|
+
listOf: listOf,
|
|
552
|
+
};
|