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/scan.js
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
// One night's work, end to end.
|
|
2
|
+
//
|
|
3
|
+
// read the app's shape -> rebuild it somewhere safe -> seed two fake
|
|
4
|
+
// people -> attack the copy -> say what got through -> delete the copy
|
|
5
|
+
//
|
|
6
|
+
// The live app is read and never written to. Nothing is copied except the
|
|
7
|
+
// shape: no rows, no customers, no orders. Every row the attack reads is a row
|
|
8
|
+
// this tool put there itself, which is what makes the report safe to send.
|
|
9
|
+
//
|
|
10
|
+
// Run with:
|
|
11
|
+
// KN_DATABASE_URL=postgresql://... node scan.js <schema>
|
|
12
|
+
// KN_DATABASE_URL=postgresql://... node scan.js <schema> --recheck
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { Client } = require('pg');
|
|
17
|
+
const schema = require('./schema.js');
|
|
18
|
+
const attack = require('./attack.js');
|
|
19
|
+
const finding = require('./finding.js');
|
|
20
|
+
const collision = require('./collision.js');
|
|
21
|
+
const tamper = require('./tamper.js');
|
|
22
|
+
const orphan = require('./orphan.js');
|
|
23
|
+
const recheck = require('./recheck.js');
|
|
24
|
+
|
|
25
|
+
// Where the last run is kept so the next one has something to compare against.
|
|
26
|
+
// Beside the person's own project, not in a temp folder, because a re-check a
|
|
27
|
+
// week later has to find it.
|
|
28
|
+
const LAST_RUN = '.kryptheon-last.json';
|
|
29
|
+
|
|
30
|
+
const line = (text) => process.stdout.write(text + '\n');
|
|
31
|
+
|
|
32
|
+
// How long a copy has to be lying around before it is treated as abandoned.
|
|
33
|
+
// Long enough that a scan running in another window is never swept out from
|
|
34
|
+
// under itself; short enough that nobody finds week-old schemas in their
|
|
35
|
+
// database.
|
|
36
|
+
const ABANDONED_AFTER = 6 * 60 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Drops copies an earlier run left behind.
|
|
40
|
+
*
|
|
41
|
+
* The copy's name carries the moment it was made, so its age can be read
|
|
42
|
+
* without asking Postgres - which does not record when a schema was created.
|
|
43
|
+
* Anything younger than a few hours is left strictly alone: it may well belong
|
|
44
|
+
* to a scan that is running right now.
|
|
45
|
+
*/
|
|
46
|
+
async function sweepOldCopies(client, mine) {
|
|
47
|
+
const dropped = [];
|
|
48
|
+
let rows = [];
|
|
49
|
+
try {
|
|
50
|
+
({ rows } = await client.query(
|
|
51
|
+
// Copies, and the engine schemas the SQL door installs. A copy is
|
|
52
|
+
// kn_<moment>; an engine is kn_engine_<moment>. The engine used to be
|
|
53
|
+
// outside this pattern entirely, so one abandoned by a dropped
|
|
54
|
+
// connection sat in the customer's database for ever - which is the
|
|
55
|
+
// one thing this product promises never to do.
|
|
56
|
+
"SELECT nspname FROM pg_namespace WHERE nspname ~ '^kn_(engine_)?[0-9a-z]+$' AND nspname <> $1",
|
|
57
|
+
[mine],
|
|
58
|
+
));
|
|
59
|
+
} catch (err) {
|
|
60
|
+
return dropped;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const row of rows) {
|
|
64
|
+
// The moment is whatever follows the last underscore, which is the
|
|
65
|
+
// whole name after kn_ for a copy and the tail for an engine.
|
|
66
|
+
const parts = String(row.nspname).split('_');
|
|
67
|
+
const made = parseInt(parts[parts.length - 1], 36);
|
|
68
|
+
if (!Number.isFinite(made) || Date.now() - made < ABANDONED_AFTER) continue;
|
|
69
|
+
try {
|
|
70
|
+
await client.query('DROP SCHEMA IF EXISTS ' + schema.quote(row.nspname) + ' CASCADE');
|
|
71
|
+
dropped.push(row.nspname);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
// Not ours to force. Better to leave it than to fail somebody's scan.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return dropped;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Every schema in the database, and how many tables are in each.
|
|
81
|
+
*
|
|
82
|
+
* Nothing filtered out. The first version left out anything named like one of
|
|
83
|
+
* our copies, which made the scan refuse to look at a schema whose name merely
|
|
84
|
+
* began the same way - and that took the entire shapes suite down a minute
|
|
85
|
+
* after this function was written. What to hide is a question for the
|
|
86
|
+
* suggestion below, not for whether a schema exists.
|
|
87
|
+
*/
|
|
88
|
+
async function schemasWithTables(client) {
|
|
89
|
+
const { rows } = await client.query(
|
|
90
|
+
`SELECT n.nspname AS schema, count(c.oid) FILTER (WHERE c.relkind = 'r')::int AS tables
|
|
91
|
+
FROM pg_namespace n
|
|
92
|
+
LEFT JOIN pg_class c ON c.relnamespace = n.oid
|
|
93
|
+
WHERE n.nspname NOT LIKE 'pg\\_%'
|
|
94
|
+
AND n.nspname <> 'information_schema'
|
|
95
|
+
GROUP BY 1
|
|
96
|
+
ORDER BY 2 DESC, 1`,
|
|
97
|
+
);
|
|
98
|
+
return rows;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* What to try instead.
|
|
103
|
+
*
|
|
104
|
+
* A person who mistyped a schema name is about to mistype it again. Listing
|
|
105
|
+
* what is actually there costs one query and saves the second attempt - but
|
|
106
|
+
* not the throwaway copies, which are ours and about to be deleted.
|
|
107
|
+
*/
|
|
108
|
+
function suggest(schemas) {
|
|
109
|
+
const worth = schemas.filter((entry) => entry.tables > 0 && !/^kn_[0-9a-z]+$/.test(entry.schema));
|
|
110
|
+
if (!worth.length) return '\n This database has no schema with tables in it.';
|
|
111
|
+
return '\n Schemas in this database with tables in them: ' +
|
|
112
|
+
worth.map((entry) => entry.schema + ' (' + entry.tables + ')').join(', ');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Runs the whole thing and hands back what got through.
|
|
117
|
+
*
|
|
118
|
+
* The copy is dropped in a `finally`, so a crash halfway does not leave a
|
|
119
|
+
* database behind with somebody's schema in it.
|
|
120
|
+
*/
|
|
121
|
+
async function scan(client, sourceSchema, options) {
|
|
122
|
+
const opts = options || {};
|
|
123
|
+
const copyName = 'kn_' + Date.now().toString(36);
|
|
124
|
+
const say = opts.quiet ? () => {} : line;
|
|
125
|
+
|
|
126
|
+
// Anything an earlier run could not clean up after itself. The copy is
|
|
127
|
+
// dropped in a `finally`, but a `finally` needs a connection: when the link
|
|
128
|
+
// dies mid-scan the process goes with it and the copy is simply left in the
|
|
129
|
+
// customer's database. Found by exactly that happening on a real schema.
|
|
130
|
+
const swept = await sweepOldCopies(client, copyName);
|
|
131
|
+
if (swept.length) say(' Cleared ' + swept.length + ' copy left by an earlier run.');
|
|
132
|
+
|
|
133
|
+
// Is there anything here at all?
|
|
134
|
+
//
|
|
135
|
+
// Typed `pubic` instead of `public`, this used to read a schema that does
|
|
136
|
+
// not exist, find no tables, attack none of them, and print "Nothing got
|
|
137
|
+
// through. Your data held." A typo produced the one sentence the entire
|
|
138
|
+
// product is sold on, and exited 0 so a script would call it a pass.
|
|
139
|
+
const elsewhere = await schemasWithTables(client);
|
|
140
|
+
const here = elsewhere.find((entry) => entry.schema === sourceSchema);
|
|
141
|
+
if (!here) {
|
|
142
|
+
return {
|
|
143
|
+
stopped: 'There is no schema called "' + sourceSchema + '" in this database.' +
|
|
144
|
+
suggest(elsewhere),
|
|
145
|
+
findings: [],
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
say(' Reading the shape of ' + sourceSchema + ' ...');
|
|
150
|
+
const plan = await schema.readSchema(client, sourceSchema);
|
|
151
|
+
|
|
152
|
+
if (!plan.tables.length) {
|
|
153
|
+
// Nothing wrong with the database, but nothing was tested either, and
|
|
154
|
+
// those are not the same answer.
|
|
155
|
+
return {
|
|
156
|
+
stopped: 'The schema "' + sourceSchema + '" has no tables in it, so there was nothing to attack.' +
|
|
157
|
+
suggest(elsewhere.filter((entry) => entry.schema !== sourceSchema)),
|
|
158
|
+
findings: [],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (plan.unsupported.length) {
|
|
163
|
+
// Attacking a copy that is missing pieces would produce verdicts about a
|
|
164
|
+
// database nobody runs, so this stops rather than guesses.
|
|
165
|
+
return {
|
|
166
|
+
stopped: 'Parts of this app could not be copied faithfully:\n ' + plan.unsupported.join('\n '),
|
|
167
|
+
findings: [],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
say(' ' + plan.tables.length + ' tables, ' + plan.policies.length + ' rules. Building a copy ...');
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
await schema.writeSchema(client, plan, copyName);
|
|
175
|
+
const copyPlan = await schema.readSchema(client, copyName);
|
|
176
|
+
|
|
177
|
+
// The copy has to be the app, or nothing that follows means anything.
|
|
178
|
+
const differences = schema.diffSchemas(plan, copyPlan);
|
|
179
|
+
if (differences.length) {
|
|
180
|
+
return {
|
|
181
|
+
stopped: 'The copy did not come out identical, so nothing was attacked:\n ' + differences.join('\n '),
|
|
182
|
+
findings: [],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
say(' Copy matches. Seeding two people and attacking it ...');
|
|
187
|
+
// The stand-ins built for tables outside the schema belong to this tool,
|
|
188
|
+
// not to the customer. Attacking them would produce findings about a table
|
|
189
|
+
// that does not exist in their app.
|
|
190
|
+
// Named one by one from what was actually built, not matched on a prefix.
|
|
191
|
+
// A customer table whose name merely looked like ours used to be dropped
|
|
192
|
+
// from every attack, and a table nobody attacks has nothing to report.
|
|
193
|
+
const standIns = new Set((plan.external || []).map((entry) => entry.stub));
|
|
194
|
+
const theirs = copyPlan.tables.filter((table) => !standIns.has(table.name));
|
|
195
|
+
const sown = await attack.seed(client, copyName, theirs);
|
|
196
|
+
|
|
197
|
+
// Views are read but never seeded: they have no rows of their own, they
|
|
198
|
+
// show the rows of the tables underneath. That is exactly why they matter
|
|
199
|
+
// - a view runs with its creator's rights unless it says otherwise, so one
|
|
200
|
+
// over a protected table hands out every row in it while the policy sits
|
|
201
|
+
// there intact and the dashboard stays green.
|
|
202
|
+
const views = (copyPlan.views || []).map((view) => ({
|
|
203
|
+
name: view.name,
|
|
204
|
+
columns: view.columns || [],
|
|
205
|
+
constraints: [],
|
|
206
|
+
rlsEnabled: false,
|
|
207
|
+
isView: true,
|
|
208
|
+
}));
|
|
209
|
+
const impersonation = await attack.impersonate(client, copyName, theirs.concat(views));
|
|
210
|
+
|
|
211
|
+
// Every attack genuinely run, named the way a finding is named. The
|
|
212
|
+
// re-check needs this: a finding that disappears because its attack never
|
|
213
|
+
// ran this time is not a finding that was fixed, and without this list the
|
|
214
|
+
// two are indistinguishable.
|
|
215
|
+
//
|
|
216
|
+
// Two conditions, both required. The read has to have produced a verdict,
|
|
217
|
+
// AND the table has to have had a row in it - because a table nothing
|
|
218
|
+
// could be seeded into returns nothing to a stranger for a reason that has
|
|
219
|
+
// nothing to do with being secure.
|
|
220
|
+
const sownTables = new Set(sown.seeded.map((entry) => entry.table));
|
|
221
|
+
// A view counts as tested once anything underneath it has rows, since that
|
|
222
|
+
// is what it has to show.
|
|
223
|
+
const viewNames = new Set(views.map((view) => view.name));
|
|
224
|
+
const attempted = impersonation.completed.filter((key) => {
|
|
225
|
+
const name = key.split(':')[1];
|
|
226
|
+
return sownTables.has(name) || (viewNames.has(name) && sown.seeded.length > 0);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Surfaced, not swallowed. A table with no row in it reads as a safe
|
|
230
|
+
// table, so the one thing that must never happen is reporting an app as
|
|
231
|
+
// clear when part of it was never actually tried.
|
|
232
|
+
const notChecked = sown.skipped.slice();
|
|
233
|
+
|
|
234
|
+
// A read that errored for any reason other than the table being closed to
|
|
235
|
+
// that role decided nothing at all, and zero rows back from a failed query
|
|
236
|
+
// looks exactly like zero rows back from a table that held.
|
|
237
|
+
for (const stuck of impersonation.blocked) {
|
|
238
|
+
if (!sownTables.has(stuck.table) && !viewNames.has(stuck.table)) continue;
|
|
239
|
+
notChecked.push({ table: stuck.table, key: stuck.key, why: stuck.why });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Can a stranger change any of it? Every write here is rolled back, so the
|
|
243
|
+
// copy comes out of this exactly as it went in and anything running after
|
|
244
|
+
// reads the same database the earlier attacks did.
|
|
245
|
+
say(' Trying to change data that is not ours ...');
|
|
246
|
+
// The copy's own policies, not the app's - they are the same rules, and
|
|
247
|
+
// the copy is what was actually attacked. The report needs them to say
|
|
248
|
+
// which rule let a write through rather than assuming one.
|
|
249
|
+
const writes = await tamper.tamper(client, copyName, theirs, sown.seeded, copyPlan.policies);
|
|
250
|
+
for (const key of writes.completed) attempted.push(key);
|
|
251
|
+
for (const stuck of writes.blocked) {
|
|
252
|
+
notChecked.push({ table: stuck.table, key: stuck.key, why: stuck.why });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Can a half-finished write survive? Rolled back like the writes above.
|
|
256
|
+
say(' Looking for rows that could point at nothing ...');
|
|
257
|
+
const stranded = await orphan.orphan(client, copyName, theirs, sown.seeded);
|
|
258
|
+
for (const key of stranded.completed) attempted.push(key);
|
|
259
|
+
for (const missed of stranded.notTried) {
|
|
260
|
+
notChecked.push({
|
|
261
|
+
table: missed.table + '.' + missed.column,
|
|
262
|
+
key: 'orphaned:' + missed.table + ':' + missed.column,
|
|
263
|
+
why: missed.why,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let collisions = { findings: [], notTried: [], raced: [] };
|
|
268
|
+
if (opts.openSession) {
|
|
269
|
+
say(' Racing two requests against each other ...');
|
|
270
|
+
const one = await opts.openSession();
|
|
271
|
+
const two = await opts.openSession();
|
|
272
|
+
try {
|
|
273
|
+
collisions = await collision.collide(client, one, two, copyName, theirs, copyPlan.indexes);
|
|
274
|
+
} finally {
|
|
275
|
+
await one.end().catch(() => {});
|
|
276
|
+
await two.end().catch(() => {});
|
|
277
|
+
}
|
|
278
|
+
for (const raced of collisions.raced) {
|
|
279
|
+
attempted.push('duplicated:' + raced.table + ':' + raced.column);
|
|
280
|
+
}
|
|
281
|
+
for (const missed of collisions.notTried) {
|
|
282
|
+
notChecked.push({
|
|
283
|
+
table: missed.table + '.' + missed.column,
|
|
284
|
+
key: 'duplicated:' + missed.table + ':' + missed.column,
|
|
285
|
+
why: missed.why,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
} else {
|
|
289
|
+
// Two requests at once needs two connections. Without them the attack
|
|
290
|
+
// cannot happen at all, and a report that quietly omits it reads exactly
|
|
291
|
+
// like a report that ran it and found nothing.
|
|
292
|
+
for (const target of collision.candidates(theirs, copyPlan.indexes).filter((t) => !t.covered)) {
|
|
293
|
+
notChecked.push({
|
|
294
|
+
table: target.table + '.' + target.column,
|
|
295
|
+
key: 'duplicated:' + target.table + ':' + target.column,
|
|
296
|
+
why: 'racing two requests needs a second connection, and none was available',
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
stopped: null,
|
|
303
|
+
attacksRun: attempted.length,
|
|
304
|
+
notChecked: notChecked,
|
|
305
|
+
attempted: attempted,
|
|
306
|
+
findings: finding.describeAll(
|
|
307
|
+
impersonation.findings.concat(writes.findings, stranded.findings, collisions.findings),
|
|
308
|
+
),
|
|
309
|
+
};
|
|
310
|
+
} finally {
|
|
311
|
+
try {
|
|
312
|
+
await client.query('DROP SCHEMA IF EXISTS ' + schema.quote(copyName) + ' CASCADE');
|
|
313
|
+
say(' Copy deleted.');
|
|
314
|
+
} catch (err) {
|
|
315
|
+
line(' WARNING: the copy ' + copyName + ' could not be deleted: ' + err.message);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The last run, or null if there has not been one worth keeping. */
|
|
321
|
+
function loadLastRun(file) {
|
|
322
|
+
try {
|
|
323
|
+
const saved = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
324
|
+
return saved && Array.isArray(saved.findings) ? saved : null;
|
|
325
|
+
} catch (err) {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Keeps a run so the next one can be measured against it.
|
|
332
|
+
*
|
|
333
|
+
* A run that stopped is never saved. Overwriting a real list of problems with
|
|
334
|
+
* an empty one from a run that fell over would quietly lose every finding, and
|
|
335
|
+
* the re-check after that would have nothing to hold anybody to.
|
|
336
|
+
*/
|
|
337
|
+
function saveRun(file, result) {
|
|
338
|
+
if (result.stopped) return;
|
|
339
|
+
try {
|
|
340
|
+
fs.writeFileSync(file, JSON.stringify(result, null, 2));
|
|
341
|
+
} catch (err) {
|
|
342
|
+
line(' WARNING: I could not save this run to ' + file + ', so tomorrow I');
|
|
343
|
+
line(' will have nothing to compare against: ' + err.message);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The last thing on the screen: what was not tested, and why.
|
|
349
|
+
*
|
|
350
|
+
* A report that ends on "nothing got through" is read as "I am safe", and the
|
|
351
|
+
* distance between those two sentences is the whole product. So the last
|
|
352
|
+
* words are always the limits of the answer, not the answer.
|
|
353
|
+
*
|
|
354
|
+
* It is printed even when nothing was missed, because "I tested all of it"
|
|
355
|
+
* and "I did not tell you what I skipped" look identical when the section is
|
|
356
|
+
* simply absent, and only one of them is good news.
|
|
357
|
+
*/
|
|
358
|
+
function notTestedLines(result) {
|
|
359
|
+
const notChecked = result.notChecked || [];
|
|
360
|
+
const lines = ['', ' ' + '-'.repeat(68), ' What I did not test', ''];
|
|
361
|
+
|
|
362
|
+
if (!notChecked.length) {
|
|
363
|
+
lines.push(' Every table I could reach was attacked, and every attack I have');
|
|
364
|
+
lines.push(' finished, ran.');
|
|
365
|
+
} else {
|
|
366
|
+
for (const missed of notChecked) {
|
|
367
|
+
lines.push(' ' + missed.table + ' - ' + missed.why);
|
|
368
|
+
}
|
|
369
|
+
lines.push('');
|
|
370
|
+
lines.push(' These are unknown, not clear. An attack that never ran comes back');
|
|
371
|
+
lines.push(' looking exactly like an attack that was refused.');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Said every time, whether or not the collision attack ran. It is the one
|
|
375
|
+
// limit that is structural rather than circumstantial, and a person
|
|
376
|
+
// deciding how far to trust this deserves to know it is there.
|
|
377
|
+
lines.push('');
|
|
378
|
+
lines.push(' One thing is never reported, on purpose: the lost update - two');
|
|
379
|
+
lines.push(' withdrawals of 100 from a balance of 100 that both go through.');
|
|
380
|
+
lines.push(' Every Postgres database behaves that way unless the app asks it');
|
|
381
|
+
lines.push(' not to, so whether yours is affected depends on code I never see.');
|
|
382
|
+
lines.push(' Flagging it would mean flagging every app with a number in it.');
|
|
383
|
+
lines.push('');
|
|
384
|
+
|
|
385
|
+
return lines;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** The report, as the person reads it in the morning. */
|
|
389
|
+
function report(result) {
|
|
390
|
+
if (result.stopped) {
|
|
391
|
+
line('');
|
|
392
|
+
line(' I could not check this app.');
|
|
393
|
+
line('');
|
|
394
|
+
line(' ' + result.stopped);
|
|
395
|
+
line('');
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Said before anything else, and said even on a clean night. "Everything
|
|
400
|
+
// held" is only true about the tables that were actually tried, and a table
|
|
401
|
+
// no row could be put into looks exactly like a table nothing got out of.
|
|
402
|
+
const notChecked = result.notChecked || [];
|
|
403
|
+
// Said before the verdict, and again in full at the very end. The warning
|
|
404
|
+
// has to come first or "everything held" is read before the reason it might
|
|
405
|
+
// not mean anything; the list has to come last or it is the first thing
|
|
406
|
+
// scrolled past. The two are not the same sentence twice - this one is
|
|
407
|
+
// whether to trust the verdict, and the one at the end is what to go and
|
|
408
|
+
// look at.
|
|
409
|
+
const sayWhatWasMissed = () => {
|
|
410
|
+
if (!notChecked.length) return;
|
|
411
|
+
line('');
|
|
412
|
+
line(' ' + notChecked.length + (notChecked.length === 1 ? ' table was' : ' tables were') +
|
|
413
|
+
' NOT checked. Whatever this report says next is not');
|
|
414
|
+
line(' about ' + (notChecked.length === 1 ? 'it' : 'them') + '. Treat ' +
|
|
415
|
+
(notChecked.length === 1 ? 'it' : 'them') + ' as unknown, not as clear - the list, and why, is');
|
|
416
|
+
line(' at the end.');
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
if (!result.findings.length) {
|
|
420
|
+
if (notChecked.length) {
|
|
421
|
+
sayWhatWasMissed();
|
|
422
|
+
line('');
|
|
423
|
+
line(' Everything I could try, held.');
|
|
424
|
+
line('');
|
|
425
|
+
} else {
|
|
426
|
+
finding.allClearLines(result.attacksRun).forEach(line);
|
|
427
|
+
}
|
|
428
|
+
notTestedLines(result).forEach(line);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
sayWhatWasMissed();
|
|
433
|
+
|
|
434
|
+
const count = result.findings.length;
|
|
435
|
+
line('');
|
|
436
|
+
line(' ' + count + (count === 1 ? ' problem' : ' problems') + ' found.');
|
|
437
|
+
|
|
438
|
+
for (const item of result.findings) {
|
|
439
|
+
line('');
|
|
440
|
+
line(' ' + '-'.repeat(68));
|
|
441
|
+
line(' ' + item.severity + ' ' + item.table);
|
|
442
|
+
line('');
|
|
443
|
+
line(' ' + item.headline);
|
|
444
|
+
line('');
|
|
445
|
+
for (const paragraph of [item.body, item.cause]) {
|
|
446
|
+
wrap(paragraph, 70).forEach((l) => line(' ' + l));
|
|
447
|
+
line('');
|
|
448
|
+
}
|
|
449
|
+
line(' Paste this into Lovable, Claude or Cursor:');
|
|
450
|
+
line('');
|
|
451
|
+
item.fixPrompt.split('\n').forEach((l) => line(' ' + l));
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
line('');
|
|
455
|
+
line(' ' + '-'.repeat(68));
|
|
456
|
+
line(' Nothing here touched your live app. I built a copy, attacked the copy,');
|
|
457
|
+
line(' and deleted it. Not one real customer was involved.');
|
|
458
|
+
|
|
459
|
+
notTestedLines(result).forEach(line);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Wraps at a width a person can read without their eyes sliding off. */
|
|
463
|
+
function wrap(text, width) {
|
|
464
|
+
const words = String(text).split(/\s+/);
|
|
465
|
+
const lines = [];
|
|
466
|
+
let current = '';
|
|
467
|
+
for (const word of words) {
|
|
468
|
+
if ((current + ' ' + word).trim().length > width) {
|
|
469
|
+
lines.push(current.trim());
|
|
470
|
+
current = word;
|
|
471
|
+
} else {
|
|
472
|
+
current = (current + ' ' + word).trim();
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (current) lines.push(current.trim());
|
|
476
|
+
return lines;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function main() {
|
|
480
|
+
const args = process.argv.slice(2).filter((arg) => arg !== '--recheck');
|
|
481
|
+
const again = process.argv.includes('--recheck');
|
|
482
|
+
|
|
483
|
+
// The connection string is the key to the customer's whole database, and
|
|
484
|
+
// typed on the command line it goes into shell history and into the process
|
|
485
|
+
// list where anybody on the machine can read it. The environment variable is
|
|
486
|
+
// the way it should be given; the argument still works because taking it
|
|
487
|
+
// away would break anyone already using it.
|
|
488
|
+
const fromEnvironment = process.env.KN_DATABASE_URL;
|
|
489
|
+
const connection = args.length > 1 ? args[0] : fromEnvironment;
|
|
490
|
+
const target = args.length > 1 ? args[1] : args[0];
|
|
491
|
+
|
|
492
|
+
if (!connection || !target) {
|
|
493
|
+
console.error('');
|
|
494
|
+
console.error(' KN_DATABASE_URL=postgresql://... node scan.js <schema> [--recheck]');
|
|
495
|
+
console.error(' node scan.js "<connection string>" <schema> [--recheck]');
|
|
496
|
+
console.error('');
|
|
497
|
+
console.error(' <schema> is usually "public".');
|
|
498
|
+
console.error('');
|
|
499
|
+
process.exit(2);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const file = path.resolve(LAST_RUN);
|
|
503
|
+
const before = again ? loadLastRun(file) : null;
|
|
504
|
+
if (again && !before) {
|
|
505
|
+
// Running a fresh scan and calling it a re-check would report every
|
|
506
|
+
// problem as new and confirm nothing, which reads like an answer.
|
|
507
|
+
console.error('');
|
|
508
|
+
console.error(' There is no earlier run here to compare against.');
|
|
509
|
+
console.error('');
|
|
510
|
+
console.error(' Run it without --recheck first, fix what it finds, then come back.');
|
|
511
|
+
console.error('');
|
|
512
|
+
process.exit(2);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const client = new Client({ connectionString: connection });
|
|
516
|
+
await client.connect();
|
|
517
|
+
try {
|
|
518
|
+
const result = await scan(client, target, {
|
|
519
|
+
// Two requests arriving together cannot be faked down one connection, so
|
|
520
|
+
// the collision attack is handed a way to open its own.
|
|
521
|
+
openSession: async () => {
|
|
522
|
+
const extra = new Client({ connectionString: connection });
|
|
523
|
+
await extra.connect();
|
|
524
|
+
return extra;
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
if (!before) {
|
|
529
|
+
report(result);
|
|
530
|
+
saveRun(file, result);
|
|
531
|
+
// Three answers, three codes: 0 clean, 1 problems found, 2 could not
|
|
532
|
+
// run. A scan that stopped used to exit 0 with no findings, which is
|
|
533
|
+
// what any script watching it would read as a pass.
|
|
534
|
+
process.exitCode = result.stopped ? 2 : result.findings.length ? 1 : 0;
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const verdict = recheck.compare(before, result);
|
|
539
|
+
recheck.describe(verdict).forEach(line);
|
|
540
|
+
recheck.badgeLines(verdict, result.attacksRun || 0).forEach(line);
|
|
541
|
+
// The verdict says what changed; this says what to do about what did not.
|
|
542
|
+
if (result.findings.length) report(result);
|
|
543
|
+
saveRun(file, result);
|
|
544
|
+
process.exitCode = result.stopped ? 2 : verdict.allClear ? 0 : 1;
|
|
545
|
+
} finally {
|
|
546
|
+
await client.end();
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (require.main === module) {
|
|
551
|
+
main().catch((err) => {
|
|
552
|
+
console.error('');
|
|
553
|
+
console.error(' The scan could not run: ' + err.message);
|
|
554
|
+
console.error('');
|
|
555
|
+
process.exit(1);
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
module.exports = {
|
|
560
|
+
scan: scan,
|
|
561
|
+
report: report,
|
|
562
|
+
wrap: wrap,
|
|
563
|
+
loadLastRun: loadLastRun,
|
|
564
|
+
saveRun: saveRun,
|
|
565
|
+
sweepOldCopies: sweepOldCopies,
|
|
566
|
+
notTestedLines: notTestedLines,
|
|
567
|
+
LAST_RUN: LAST_RUN,
|
|
568
|
+
ABANDONED_AFTER: ABANDONED_AFTER,
|
|
569
|
+
};
|