cyberhub-pracenv 1.0.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.
@@ -0,0 +1,442 @@
1
+ 'use strict';
2
+
3
+ // Command handlers for the in-platform shell. Each handler receives a context:
4
+ // ctx = { rl, out, cwd, requestExit() }
5
+ // and the parsed argument array. Handlers may be async (the upload wizard etc.).
6
+ //
7
+ // The command table at the bottom drives both dispatch and the `help` listing.
8
+
9
+ const path = require('path');
10
+ const store = require('./store');
11
+ const auth = require('./auth');
12
+ const ui = require('./ui');
13
+ const { c } = ui;
14
+
15
+ function print(ctx, ...lines) {
16
+ ctx.out(lines.join('\n'));
17
+ }
18
+
19
+ function requireAdmin(ctx) {
20
+ if (!auth.isAdmin()) {
21
+ print(ctx, c.err('Permission denied: this command is admin-only.'));
22
+ print(ctx, c.dim('Run `admin` and enter the admin password first.'));
23
+ return false;
24
+ }
25
+ return true;
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // User commands
30
+ // ---------------------------------------------------------------------------
31
+
32
+ function cmdHelp(ctx) {
33
+ const userCmds = [
34
+ ['challenges (ls)', 'List all available challenges'],
35
+ ['open <id>', 'Show a challenge: prompt, points, attached files'],
36
+ ['files <id>', 'List the files attached to a challenge'],
37
+ ['cat <id> <file>', 'Print the contents of an attached file'],
38
+ ['get <id> <file>', 'Copy an attached file into your launch directory'],
39
+ ['submit <id> <flag>', 'Submit a flag to solve a challenge'],
40
+ ['stats (whoami)', 'Show your full user breakdown'],
41
+ ['admin', 'Unlock admin mode (requires the admin password)'],
42
+ ['clear', 'Clear the screen'],
43
+ ['help', 'Show this help'],
44
+ ['exit (quit)', 'Leave the platform'],
45
+ ];
46
+ const adminCmds = [
47
+ ['upload', 'Launch the challenge upload wizard (writes a .yml)'],
48
+ ['addfile <id> <path>', 'Attach a file from disk to an existing challenge'],
49
+ ['rmchallenge <id>', 'Delete a challenge and its files'],
50
+ ['passwd', 'Change the admin password'],
51
+ ['logout', 'Leave admin mode'],
52
+ ];
53
+
54
+ print(ctx, c.title('Available commands'));
55
+ print(ctx, ui.table(userCmds.map(([name, desc]) => ({ name, desc })), [
56
+ { key: 'name', label: 'Command', color: c.accent },
57
+ { key: 'desc', label: 'Description' },
58
+ ]));
59
+
60
+ if (auth.isAdmin()) {
61
+ print(ctx, '', c.title('Admin commands'));
62
+ print(ctx, ui.table(adminCmds.map(([name, desc]) => ({ name, desc })), [
63
+ { key: 'name', label: 'Command', color: c.flag },
64
+ { key: 'desc', label: 'Description' },
65
+ ]));
66
+ }
67
+ }
68
+
69
+ function cmdChallenges(ctx) {
70
+ const profile = store.getProfile();
71
+ const challenges = store.listChallenges();
72
+ if (challenges.length === 0) {
73
+ print(ctx, c.warn('No challenges available yet.'));
74
+ return;
75
+ }
76
+ const rows = challenges.map((ch) => ({
77
+ solved: profile.solved.includes(ch.id) ? '✔' : ' ',
78
+ id: ch.id,
79
+ title: ch.title,
80
+ category: ch.category,
81
+ difficulty: ch.difficulty,
82
+ points: ch.points,
83
+ files: (ch.files || []).length,
84
+ }));
85
+ print(ctx, c.title('Challenges'));
86
+ print(ctx, ui.table(rows, [
87
+ { key: 'solved', label: '✔', color: c.ok },
88
+ { key: 'id', label: 'ID', color: c.accent },
89
+ { key: 'title', label: 'Title' },
90
+ { key: 'category', label: 'Category' },
91
+ { key: 'difficulty', label: 'Difficulty' },
92
+ { key: 'points', label: 'Pts' },
93
+ { key: 'files', label: 'Files' },
94
+ ]));
95
+ print(ctx, c.dim('\nUse `open <id>` to view a challenge.'));
96
+ }
97
+
98
+ function cmdOpen(ctx, args) {
99
+ const id = args[0];
100
+ if (!id) {
101
+ print(ctx, c.err('Usage: open <id>'));
102
+ return;
103
+ }
104
+ const ch = store.getChallenge(id);
105
+ if (!ch) {
106
+ print(ctx, c.err(`No challenge with id "${id}".`));
107
+ return;
108
+ }
109
+ const solved = store.getProfile().solved.includes(ch.id);
110
+ print(ctx, c.title(ch.title) + (solved ? ' ' + c.ok('[SOLVED]') : ''));
111
+ print(ctx, c.dim(`id: ${ch.id} category: ${ch.category} difficulty: ${ch.difficulty} points: ${ch.points}`));
112
+ print(ctx, '');
113
+ print(ctx, String(ch.description || '').trimEnd());
114
+ print(ctx, '');
115
+ if ((ch.files || []).length > 0) {
116
+ print(ctx, c.bold('Attached files:'));
117
+ for (const f of ch.files) print(ctx, ' - ' + c.accent(f));
118
+ print(ctx, c.dim('View with `cat ' + ch.id + ' <file>` or download with `get ' + ch.id + ' <file>`.'));
119
+ } else {
120
+ print(ctx, c.dim('No files attached.'));
121
+ }
122
+ print(ctx, c.dim('\nSubmit your answer with `submit ' + ch.id + ' <flag>`.'));
123
+ }
124
+
125
+ function cmdFiles(ctx, args) {
126
+ const id = args[0];
127
+ if (!id) {
128
+ print(ctx, c.err('Usage: files <id>'));
129
+ return;
130
+ }
131
+ const ch = store.getChallenge(id);
132
+ if (!ch) {
133
+ print(ctx, c.err(`No challenge with id "${id}".`));
134
+ return;
135
+ }
136
+ if ((ch.files || []).length === 0) {
137
+ print(ctx, c.dim('No files attached to this challenge.'));
138
+ return;
139
+ }
140
+ print(ctx, c.title(`Files for ${ch.id}`));
141
+ for (const f of ch.files) print(ctx, ' - ' + c.accent(f));
142
+ }
143
+
144
+ function cmdCat(ctx, args) {
145
+ const [id, name] = args;
146
+ if (!id || !name) {
147
+ print(ctx, c.err('Usage: cat <id> <file>'));
148
+ return;
149
+ }
150
+ if (!store.getChallenge(id)) {
151
+ print(ctx, c.err(`No challenge with id "${id}".`));
152
+ return;
153
+ }
154
+ if (!store.hasChallengeFile(id, name)) {
155
+ print(ctx, c.err(`No file "${name}" attached to "${id}".`));
156
+ return;
157
+ }
158
+ print(ctx, c.dim(`----- ${name} -----`));
159
+ print(ctx, store.readChallengeFile(id, name).replace(/\s*$/, ''));
160
+ print(ctx, c.dim(`----- end of ${name} -----`));
161
+ }
162
+
163
+ function cmdGet(ctx, args) {
164
+ const [id, name] = args;
165
+ if (!id || !name) {
166
+ print(ctx, c.err('Usage: get <id> <file>'));
167
+ return;
168
+ }
169
+ try {
170
+ const dest = store.exportFile(id, name, ctx.cwd);
171
+ print(ctx, c.ok(`Saved ${name} to ${dest}`));
172
+ } catch (err) {
173
+ print(ctx, c.err(err.message));
174
+ }
175
+ }
176
+
177
+ function cmdSubmit(ctx, args) {
178
+ const id = args[0];
179
+ const flag = args.slice(1).join(' ');
180
+ if (!id || !flag) {
181
+ print(ctx, c.err('Usage: submit <id> <flag>'));
182
+ return;
183
+ }
184
+ const { result, challenge } = store.attemptFlag(id, flag);
185
+ switch (result) {
186
+ case 'notfound':
187
+ print(ctx, c.err(`No challenge with id "${id}".`));
188
+ break;
189
+ case 'already':
190
+ print(ctx, c.warn('You have already solved this challenge.'));
191
+ break;
192
+ case 'solved':
193
+ print(ctx, c.ok(`Correct! "${challenge.title}" solved.`) + c.dim(` (+${challenge.points} pts)`));
194
+ print(ctx, c.dim(`Total points: ${store.getProfile().points}`));
195
+ break;
196
+ default:
197
+ print(ctx, c.err('Incorrect flag. Try again.'));
198
+ }
199
+ }
200
+
201
+ function cmdStats(ctx) {
202
+ const profile = store.getProfile();
203
+ const challenges = store.listChallenges();
204
+ const total = challenges.length;
205
+ const solvedCount = profile.solved.length;
206
+
207
+ // Per-category progress.
208
+ const byCat = {};
209
+ for (const ch of challenges) {
210
+ const cat = ch.category || 'misc';
211
+ byCat[cat] = byCat[cat] || { total: 0, solved: 0 };
212
+ byCat[cat].total += 1;
213
+ if (profile.solved.includes(ch.id)) byCat[cat].solved += 1;
214
+ }
215
+
216
+ print(ctx, c.title('User breakdown'));
217
+ print(ctx, ` ${c.bold('Operative')} : ${profile.name}`);
218
+ print(ctx, ` ${c.bold('Rank')} : ${c.accent(ui.rankFor(profile.points))}`);
219
+ print(ctx, ` ${c.bold('Points')} : ${c.ok(profile.points)}`);
220
+ print(ctx, ` ${c.bold('Solved')} : ${solvedCount} / ${total}`);
221
+ print(ctx, ` ${c.bold('Attempts')} : ${profile.attempts || 0}`);
222
+ print(ctx, ` ${c.bold('Member since')} : ${new Date(profile.createdAt).toLocaleString()}`);
223
+ print(ctx, ` ${c.bold('Admin mode')} : ${auth.isAdmin() ? c.ok('ON') : c.dim('off')}`);
224
+
225
+ print(ctx, '', c.bold(' Progress by category:'));
226
+ const catRows = Object.keys(byCat)
227
+ .sort()
228
+ .map((cat) => ({
229
+ category: cat,
230
+ progress: `${byCat[cat].solved} / ${byCat[cat].total}`,
231
+ }));
232
+ if (catRows.length > 0) {
233
+ print(ctx, ui.table(catRows, [
234
+ { key: 'category', label: 'Category', color: c.accent },
235
+ { key: 'progress', label: 'Solved' },
236
+ ]));
237
+ }
238
+
239
+ if (solvedCount > 0) {
240
+ print(ctx, '', c.bold(' Solved challenges:'));
241
+ for (const id of profile.solved) {
242
+ const ch = store.getChallenge(id);
243
+ print(ctx, ' ' + c.ok('✔ ') + c.accent(id) + (ch ? c.dim(' ' + ch.title) : ''));
244
+ }
245
+ }
246
+ }
247
+
248
+ async function cmdAdmin(ctx) {
249
+ if (auth.isAdmin()) {
250
+ print(ctx, c.dim('Already in admin mode.'));
251
+ return;
252
+ }
253
+ const password = await ctx.askHidden('Admin password: ');
254
+ if (store.verifyAdminPassword(password)) {
255
+ auth.setAdmin(true);
256
+ print(ctx, c.ok('Admin mode unlocked.') + c.dim(' Type `help` to see admin commands.'));
257
+ } else {
258
+ print(ctx, c.err('Incorrect admin password.'));
259
+ }
260
+ }
261
+
262
+ function cmdLogout(ctx) {
263
+ if (!auth.isAdmin()) {
264
+ print(ctx, c.dim('You are not in admin mode.'));
265
+ return;
266
+ }
267
+ auth.setAdmin(false);
268
+ print(ctx, c.ok('Left admin mode.'));
269
+ }
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // Admin commands
273
+ // ---------------------------------------------------------------------------
274
+
275
+ async function cmdUpload(ctx) {
276
+ if (!requireAdmin(ctx)) return;
277
+
278
+ print(ctx, c.title('Challenge upload wizard'));
279
+ print(ctx, c.dim('Answer the prompts to create a new challenge .yml. Leave id blank to cancel.\n'));
280
+
281
+ const id = (await ctx.ask('Challenge id (e.g. web-201): ')).trim();
282
+ if (!id) {
283
+ print(ctx, c.warn('Cancelled.'));
284
+ return;
285
+ }
286
+ if (!/^[a-zA-Z0-9._-]+$/.test(id)) {
287
+ print(ctx, c.err('Invalid id. Use letters, numbers, dots, dashes or underscores only.'));
288
+ return;
289
+ }
290
+ if (store.challengeExists(id)) {
291
+ print(ctx, c.err(`Challenge "${id}" already exists.`));
292
+ return;
293
+ }
294
+
295
+ const title = (await ctx.ask('Title: ')).trim() || id;
296
+ const category = (await ctx.ask('Category [misc]: ')).trim() || 'misc';
297
+ const difficulty = (await ctx.ask('Difficulty [easy]: ')).trim() || 'easy';
298
+ const pointsRaw = (await ctx.ask('Points [100]: ')).trim();
299
+ const points = Number(pointsRaw) || 100;
300
+ const flag = (await ctx.ask('Flag (e.g. CHPE{...}): ')).trim();
301
+
302
+ print(ctx, c.dim('Description — enter as many lines as you like. Finish with a single "." on its own line.'));
303
+ const descLines = [];
304
+ // eslint-disable-next-line no-constant-condition
305
+ while (true) {
306
+ const line = await ctx.ask(c.dim(' desc> '));
307
+ if (line.trim() === '.') break;
308
+ descLines.push(line);
309
+ }
310
+ const description = descLines.join('\n');
311
+
312
+ try {
313
+ store.createChallenge({ id, title, category, difficulty, points, flag, description, files: [] });
314
+ print(ctx, c.ok(`Created challenge "${id}".`));
315
+ print(ctx, c.dim(`Attach files with: addfile ${id} <path-to-file>`));
316
+ } catch (err) {
317
+ print(ctx, c.err(err.message));
318
+ }
319
+ }
320
+
321
+ function cmdAddFile(ctx, args) {
322
+ if (!requireAdmin(ctx)) return;
323
+ const id = args[0];
324
+ const src = args.slice(1).join(' ');
325
+ if (!id || !src) {
326
+ print(ctx, c.err('Usage: addfile <id> <path-to-file>'));
327
+ return;
328
+ }
329
+ // Resolve relative paths against the directory the platform was launched from.
330
+ const resolved = path.isAbsolute(src) ? src : path.resolve(ctx.cwd, src);
331
+ try {
332
+ const name = store.attachFile(id, resolved);
333
+ print(ctx, c.ok(`Attached "${name}" to challenge "${id}".`));
334
+ } catch (err) {
335
+ print(ctx, c.err(err.message));
336
+ }
337
+ }
338
+
339
+ function cmdRmChallenge(ctx, args) {
340
+ if (!requireAdmin(ctx)) return;
341
+ const id = args[0];
342
+ if (!id) {
343
+ print(ctx, c.err('Usage: rmchallenge <id>'));
344
+ return;
345
+ }
346
+ if (store.deleteChallenge(id)) {
347
+ print(ctx, c.ok(`Deleted challenge "${id}".`));
348
+ } else {
349
+ print(ctx, c.err(`No challenge with id "${id}".`));
350
+ }
351
+ }
352
+
353
+ async function cmdPasswd(ctx) {
354
+ if (!requireAdmin(ctx)) return;
355
+ const current = await ctx.askHidden('Current admin password: ');
356
+ if (!store.verifyAdminPassword(current)) {
357
+ print(ctx, c.err('Incorrect password.'));
358
+ return;
359
+ }
360
+ const next = await ctx.askHidden('New admin password: ');
361
+ if (next.length < 4) {
362
+ print(ctx, c.err('Password must be at least 4 characters.'));
363
+ return;
364
+ }
365
+ const confirm = await ctx.askHidden('Confirm new password: ');
366
+ if (next !== confirm) {
367
+ print(ctx, c.err('Passwords do not match.'));
368
+ return;
369
+ }
370
+ store.setAdminPassword(next);
371
+ print(ctx, c.ok('Admin password updated.'));
372
+ }
373
+
374
+ function cmdClear(ctx) {
375
+ // Clear screen + scrollback, then reprint a slim header.
376
+ ctx.out('\x1b[2J\x1b[3J\x1b[H');
377
+ print(ctx, c.title('CyberHub Practice Environment') + c.dim(' — type `help` for commands'));
378
+ }
379
+
380
+ function cmdExit(ctx) {
381
+ print(ctx, c.dim('Stay sharp. Goodbye.'));
382
+ ctx.requestExit();
383
+ }
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // Command table + dispatch
387
+ // ---------------------------------------------------------------------------
388
+
389
+ const COMMANDS = {
390
+ help: cmdHelp,
391
+ '?': cmdHelp,
392
+ challenges: cmdChallenges,
393
+ ls: cmdChallenges,
394
+ open: cmdOpen,
395
+ show: cmdOpen,
396
+ files: cmdFiles,
397
+ cat: cmdCat,
398
+ get: cmdGet,
399
+ download: cmdGet,
400
+ submit: cmdSubmit,
401
+ stats: cmdStats,
402
+ whoami: cmdStats,
403
+ profile: cmdStats,
404
+ admin: cmdAdmin,
405
+ login: cmdAdmin,
406
+ logout: cmdLogout,
407
+ upload: cmdUpload,
408
+ addfile: cmdAddFile,
409
+ rmchallenge: cmdRmChallenge,
410
+ passwd: cmdPasswd,
411
+ clear: cmdClear,
412
+ cls: cmdClear,
413
+ exit: cmdExit,
414
+ quit: cmdExit,
415
+ };
416
+
417
+ // Parse a raw input line into [command, ...args], respecting "quoted strings".
418
+ function tokenize(line) {
419
+ const tokens = [];
420
+ const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
421
+ let m;
422
+ while ((m = re.exec(line)) !== null) {
423
+ tokens.push(m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2] : m[3]);
424
+ }
425
+ return tokens;
426
+ }
427
+
428
+ async function dispatch(line, ctx) {
429
+ const tokens = tokenize(line.trim());
430
+ if (tokens.length === 0) return;
431
+ const name = tokens[0].toLowerCase();
432
+ const args = tokens.slice(1);
433
+
434
+ const handler = COMMANDS[name];
435
+ if (!handler) {
436
+ print(ctx, c.err(`Unknown command: ${name}`) + c.dim(' (type `help`)'));
437
+ return;
438
+ }
439
+ await handler(ctx, args);
440
+ }
441
+
442
+ module.exports = { dispatch, tokenize, COMMANDS };
package/src/input.js ADDED
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ // Queue-based line reader over a readline interface.
4
+ //
5
+ // Using rl.question in a loop loses lines when several arrive between questions
6
+ // (which happens whenever input is piped). Instead we listen to the 'line'
7
+ // event continuously and buffer lines, handing them out one at a time. This
8
+ // behaves correctly for both an interactive TTY and piped/scripted input.
9
+
10
+ function createInput(rl) {
11
+ const queue = [];
12
+ const waiters = [];
13
+ let closed = false;
14
+
15
+ rl.on('line', (line) => {
16
+ const waiter = waiters.shift();
17
+ if (waiter) waiter(line);
18
+ else queue.push(line);
19
+ });
20
+
21
+ rl.on('close', () => {
22
+ closed = true;
23
+ while (waiters.length) waiters.shift()(null);
24
+ });
25
+
26
+ function nextLine() {
27
+ if (queue.length) return Promise.resolve(queue.shift());
28
+ if (closed) return Promise.resolve(null);
29
+ return new Promise((resolve) => waiters.push(resolve));
30
+ }
31
+
32
+ // Print a prompt, then resolve with the next line ('' on EOF).
33
+ async function ask(query) {
34
+ if (query) rl.output.write(query);
35
+ const line = await nextLine();
36
+ return line == null ? '' : line;
37
+ }
38
+
39
+ // Like ask, but suppresses echo of typed characters (password entry).
40
+ async function askHidden(query) {
41
+ rl.output.write(query);
42
+ rl.stdoutMuted = true;
43
+ const line = await nextLine();
44
+ rl.stdoutMuted = false;
45
+ rl.output.write('\n');
46
+ return line == null ? '' : line;
47
+ }
48
+
49
+ return {
50
+ ask,
51
+ askHidden,
52
+ nextLine,
53
+ get closed() {
54
+ return closed;
55
+ },
56
+ };
57
+ }
58
+
59
+ module.exports = { createInput };
package/src/repl.js ADDED
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ // The in-platform shell. Drives an async prompt loop over the shared queue-based
4
+ // line reader so the same input source feeds both the main loop and sub-prompts
5
+ // (the upload wizard, password entry).
6
+
7
+ const { dispatch } = require('./commands');
8
+ const auth = require('./auth');
9
+ const ui = require('./ui');
10
+ const { c } = ui;
11
+
12
+ function promptString() {
13
+ return auth.isAdmin()
14
+ ? c.flag('cyberhub') + c.dim('(') + c.err('admin') + c.dim(')> ')
15
+ : c.accent('cyberhub') + c.dim('> ');
16
+ }
17
+
18
+ function makeContext(rl, input, cwd) {
19
+ let exitRequested = false;
20
+ return {
21
+ rl,
22
+ cwd,
23
+ ask: input.ask,
24
+ askHidden: input.askHidden,
25
+ out: (text) => process.stdout.write(text + '\n'),
26
+ requestExit() {
27
+ exitRequested = true;
28
+ },
29
+ get exitRequested() {
30
+ return exitRequested;
31
+ },
32
+ get inputClosed() {
33
+ return input.closed;
34
+ },
35
+ };
36
+ }
37
+
38
+ async function startRepl(rl, input, cwd) {
39
+ const ctx = makeContext(rl, input, cwd);
40
+
41
+ ctx.out(c.dim('Type `help` for a list of commands, `exit` to leave.\n'));
42
+
43
+ // Ctrl+C: first press hints how to leave, second press exits.
44
+ let sigintArmed = false;
45
+ rl.on('SIGINT', () => {
46
+ if (sigintArmed) {
47
+ ctx.out('');
48
+ process.exit(0);
49
+ }
50
+ sigintArmed = true;
51
+ ctx.out('\n' + c.dim('Type `exit` to leave, or press Ctrl+C again to force quit.'));
52
+ process.stdout.write(promptString());
53
+ setTimeout(() => {
54
+ sigintArmed = false;
55
+ }, 2000);
56
+ });
57
+
58
+ while (!ctx.exitRequested && !ctx.inputClosed) {
59
+ const line = await ctx.ask(promptString());
60
+ if (ctx.inputClosed && line === '') break; // EOF with nothing pending
61
+ try {
62
+ await dispatch(line, ctx);
63
+ } catch (err) {
64
+ ctx.out(c.err('Error: ' + (err && err.message ? err.message : String(err))));
65
+ }
66
+ }
67
+
68
+ rl.close();
69
+ }
70
+
71
+ module.exports = { startRepl, promptString };