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.
package/src/store.js ADDED
@@ -0,0 +1,337 @@
1
+ 'use strict';
2
+
3
+ // Persistence layer for cyberhub-pracenv.
4
+ //
5
+ // Everything lives under a per-user data directory in the home folder so the
6
+ // platform works the same no matter which directory it was launched from:
7
+ //
8
+ // ~/.cyberhub-pracenv/
9
+ // config.json admin password hash + settings
10
+ // profile.json the single local user profile
11
+ // challenges/<id>.yml challenge definitions
12
+ // files/<id>/<filename> files attached to a challenge
13
+ //
14
+ // Bundled sample challenges shipped in the package (data/challenges) are copied
15
+ // into the user directory on first run so there is something to play with.
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const os = require('os');
20
+ const yaml = require('js-yaml');
21
+ const auth = require('./auth');
22
+
23
+ const DATA_DIR = path.join(os.homedir(), '.cyberhub-pracenv');
24
+ const CHALLENGES_DIR = path.join(DATA_DIR, 'challenges');
25
+ const FILES_DIR = path.join(DATA_DIR, 'files');
26
+ const CONFIG_PATH = path.join(DATA_DIR, 'config.json');
27
+ const PROFILE_PATH = path.join(DATA_DIR, 'profile.json');
28
+
29
+ // Bundled seed data shipped with the package.
30
+ const SEED_DIR = path.join(__dirname, '..', 'data', 'challenges');
31
+ const SEED_FILES_DIR = path.join(SEED_DIR, 'files');
32
+
33
+ const DEFAULT_ADMIN_PASSWORD = 'admin';
34
+
35
+ function ensureDir(dir) {
36
+ fs.mkdirSync(dir, { recursive: true });
37
+ }
38
+
39
+ function readJson(file, fallback) {
40
+ try {
41
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
42
+ } catch (_err) {
43
+ return fallback;
44
+ }
45
+ }
46
+
47
+ function writeJson(file, data) {
48
+ fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf8');
49
+ }
50
+
51
+ function copyRecursive(src, dest) {
52
+ if (!fs.existsSync(src)) return;
53
+ const stat = fs.statSync(src);
54
+ if (stat.isDirectory()) {
55
+ ensureDir(dest);
56
+ for (const entry of fs.readdirSync(src)) {
57
+ copyRecursive(path.join(src, entry), path.join(dest, entry));
58
+ }
59
+ } else {
60
+ ensureDir(path.dirname(dest));
61
+ fs.copyFileSync(src, dest);
62
+ }
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Initialisation / seeding
67
+ // ---------------------------------------------------------------------------
68
+
69
+ function init() {
70
+ ensureDir(DATA_DIR);
71
+ ensureDir(CHALLENGES_DIR);
72
+ ensureDir(FILES_DIR);
73
+
74
+ if (!fs.existsSync(CONFIG_PATH)) {
75
+ const { hash, salt } = auth.hashPassword(DEFAULT_ADMIN_PASSWORD);
76
+ writeJson(CONFIG_PATH, {
77
+ adminPasswordHash: hash,
78
+ adminPasswordSalt: salt,
79
+ createdAt: new Date().toISOString(),
80
+ });
81
+ }
82
+
83
+ if (!fs.existsSync(PROFILE_PATH)) {
84
+ writeJson(PROFILE_PATH, {
85
+ name: os.userInfo().username || 'operative',
86
+ points: 0,
87
+ solved: [],
88
+ attempts: 0,
89
+ createdAt: new Date().toISOString(),
90
+ });
91
+ }
92
+
93
+ // Seed sample challenges + their files only when the user has none yet.
94
+ if (listChallengeIds().length === 0) {
95
+ if (fs.existsSync(SEED_DIR)) {
96
+ for (const entry of fs.readdirSync(SEED_DIR)) {
97
+ if (entry.endsWith('.yml') || entry.endsWith('.yaml')) {
98
+ fs.copyFileSync(path.join(SEED_DIR, entry), path.join(CHALLENGES_DIR, entry));
99
+ }
100
+ }
101
+ }
102
+ if (fs.existsSync(SEED_FILES_DIR)) {
103
+ copyRecursive(SEED_FILES_DIR, FILES_DIR);
104
+ }
105
+ }
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Config + admin password
110
+ // ---------------------------------------------------------------------------
111
+
112
+ function getConfig() {
113
+ return readJson(CONFIG_PATH, {});
114
+ }
115
+
116
+ function verifyAdminPassword(password) {
117
+ const cfg = getConfig();
118
+ if (!cfg.adminPasswordHash || !cfg.adminPasswordSalt) return false;
119
+ return auth.verifyPassword(password, cfg.adminPasswordHash, cfg.adminPasswordSalt);
120
+ }
121
+
122
+ function setAdminPassword(password) {
123
+ const cfg = getConfig();
124
+ const { hash, salt } = auth.hashPassword(password);
125
+ cfg.adminPasswordHash = hash;
126
+ cfg.adminPasswordSalt = salt;
127
+ writeJson(CONFIG_PATH, cfg);
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Profile
132
+ // ---------------------------------------------------------------------------
133
+
134
+ function getProfile() {
135
+ return readJson(PROFILE_PATH, {
136
+ name: 'operative',
137
+ points: 0,
138
+ solved: [],
139
+ attempts: 0,
140
+ createdAt: new Date().toISOString(),
141
+ });
142
+ }
143
+
144
+ function saveProfile(profile) {
145
+ writeJson(PROFILE_PATH, profile);
146
+ }
147
+
148
+ // Record a flag attempt. Returns one of: 'solved', 'already', 'wrong', 'notfound'.
149
+ function attemptFlag(id, flag) {
150
+ const challenge = getChallenge(id);
151
+ if (!challenge) return { result: 'notfound' };
152
+
153
+ const profile = getProfile();
154
+ profile.attempts = (profile.attempts || 0) + 1;
155
+
156
+ if (profile.solved.includes(id)) {
157
+ saveProfile(profile);
158
+ return { result: 'already', challenge };
159
+ }
160
+
161
+ const submitted = String(flag).trim();
162
+ const expected = String(challenge.flag == null ? '' : challenge.flag).trim();
163
+ if (submitted.length > 0 && submitted === expected) {
164
+ profile.solved.push(id);
165
+ profile.points = (profile.points || 0) + (Number(challenge.points) || 0);
166
+ saveProfile(profile);
167
+ return { result: 'solved', challenge };
168
+ }
169
+
170
+ saveProfile(profile);
171
+ return { result: 'wrong', challenge };
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Challenges
176
+ // ---------------------------------------------------------------------------
177
+
178
+ function challengePath(id) {
179
+ return path.join(CHALLENGES_DIR, `${id}.yml`);
180
+ }
181
+
182
+ function listChallengeIds() {
183
+ if (!fs.existsSync(CHALLENGES_DIR)) return [];
184
+ return fs
185
+ .readdirSync(CHALLENGES_DIR)
186
+ .filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'))
187
+ .map((f) => f.replace(/\.ya?ml$/, ''));
188
+ }
189
+
190
+ function listChallenges() {
191
+ return listChallengeIds()
192
+ .map((id) => getChallenge(id))
193
+ .filter(Boolean)
194
+ .sort((a, b) => String(a.id).localeCompare(String(b.id)));
195
+ }
196
+
197
+ function getChallenge(id) {
198
+ const file = challengePath(id);
199
+ if (!fs.existsSync(file)) {
200
+ // Allow .yaml extension as well.
201
+ const alt = path.join(CHALLENGES_DIR, `${id}.yaml`);
202
+ if (!fs.existsSync(alt)) return null;
203
+ return loadChallengeFile(alt, id);
204
+ }
205
+ return loadChallengeFile(file, id);
206
+ }
207
+
208
+ function loadChallengeFile(file, id) {
209
+ try {
210
+ const doc = yaml.load(fs.readFileSync(file, 'utf8')) || {};
211
+ doc.id = doc.id || id;
212
+ if (!Array.isArray(doc.files)) doc.files = [];
213
+ return doc;
214
+ } catch (_err) {
215
+ return null;
216
+ }
217
+ }
218
+
219
+ function challengeExists(id) {
220
+ return getChallenge(id) !== null;
221
+ }
222
+
223
+ // Create a brand-new challenge from a plain object. Throws if id already exists.
224
+ function createChallenge(challenge) {
225
+ const id = String(challenge.id || '').trim();
226
+ if (!id) throw new Error('Challenge id is required.');
227
+ if (challengeExists(id)) throw new Error(`Challenge "${id}" already exists.`);
228
+ saveChallenge(challenge);
229
+ return id;
230
+ }
231
+
232
+ function saveChallenge(challenge) {
233
+ ensureDir(CHALLENGES_DIR);
234
+ const id = String(challenge.id).trim();
235
+ const doc = {
236
+ id,
237
+ title: challenge.title || id,
238
+ category: challenge.category || 'misc',
239
+ difficulty: challenge.difficulty || 'easy',
240
+ points: Number(challenge.points) || 0,
241
+ description: challenge.description || '',
242
+ flag: challenge.flag || '',
243
+ files: Array.isArray(challenge.files) ? challenge.files : [],
244
+ };
245
+ fs.writeFileSync(challengePath(id), yaml.dump(doc, { lineWidth: 100 }), 'utf8');
246
+ }
247
+
248
+ function deleteChallenge(id) {
249
+ const file = challengePath(id);
250
+ const alt = path.join(CHALLENGES_DIR, `${id}.yaml`);
251
+ let removed = false;
252
+ for (const f of [file, alt]) {
253
+ if (fs.existsSync(f)) {
254
+ fs.unlinkSync(f);
255
+ removed = true;
256
+ }
257
+ }
258
+ const dir = path.join(FILES_DIR, id);
259
+ if (fs.existsSync(dir)) {
260
+ fs.rmSync(dir, { recursive: true, force: true });
261
+ }
262
+ return removed;
263
+ }
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // Files attached to challenges
267
+ // ---------------------------------------------------------------------------
268
+
269
+ function challengeFileDir(id) {
270
+ return path.join(FILES_DIR, id);
271
+ }
272
+
273
+ function challengeFilePath(id, name) {
274
+ return path.join(challengeFileDir(id), path.basename(name));
275
+ }
276
+
277
+ function hasChallengeFile(id, name) {
278
+ return fs.existsSync(challengeFilePath(id, name));
279
+ }
280
+
281
+ function readChallengeFile(id, name) {
282
+ return fs.readFileSync(challengeFilePath(id, name), 'utf8');
283
+ }
284
+
285
+ // Attach a file from an arbitrary path on disk to an existing challenge.
286
+ // Copies the file into the data dir and records its name in the challenge yml.
287
+ function attachFile(id, sourcePath) {
288
+ const challenge = getChallenge(id);
289
+ if (!challenge) throw new Error(`Challenge "${id}" does not exist.`);
290
+ if (!fs.existsSync(sourcePath)) throw new Error(`File not found: ${sourcePath}`);
291
+ if (fs.statSync(sourcePath).isDirectory()) {
292
+ throw new Error(`"${sourcePath}" is a directory, not a file.`);
293
+ }
294
+
295
+ const name = path.basename(sourcePath);
296
+ ensureDir(challengeFileDir(id));
297
+ fs.copyFileSync(sourcePath, challengeFilePath(id, name));
298
+
299
+ if (!challenge.files.includes(name)) {
300
+ challenge.files.push(name);
301
+ saveChallenge(challenge);
302
+ }
303
+ return name;
304
+ }
305
+
306
+ // Copy an attached file out to a destination directory (used by the `get` command).
307
+ function exportFile(id, name, destDir) {
308
+ const src = challengeFilePath(id, name);
309
+ if (!fs.existsSync(src)) throw new Error(`No such file "${name}" on challenge "${id}".`);
310
+ ensureDir(destDir);
311
+ const dest = path.join(destDir, path.basename(name));
312
+ fs.copyFileSync(src, dest);
313
+ return dest;
314
+ }
315
+
316
+ module.exports = {
317
+ DATA_DIR,
318
+ DEFAULT_ADMIN_PASSWORD,
319
+ init,
320
+ getConfig,
321
+ verifyAdminPassword,
322
+ setAdminPassword,
323
+ getProfile,
324
+ saveProfile,
325
+ attemptFlag,
326
+ listChallengeIds,
327
+ listChallenges,
328
+ getChallenge,
329
+ challengeExists,
330
+ createChallenge,
331
+ saveChallenge,
332
+ deleteChallenge,
333
+ hasChallengeFile,
334
+ readChallengeFile,
335
+ attachFile,
336
+ exportFile,
337
+ };
package/src/ui.js ADDED
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+
3
+ // Presentation helpers: the welcome banner, colour styling, simple table
4
+ // rendering, and prompt helpers (including a masked password prompt) that all
5
+ // share the single readline interface owned by the REPL.
6
+
7
+ const chalk = require('chalk');
8
+
9
+ const c = {
10
+ title: chalk.cyanBright.bold,
11
+ accent: chalk.cyan,
12
+ ok: chalk.greenBright,
13
+ warn: chalk.yellow,
14
+ err: chalk.redBright,
15
+ dim: chalk.gray,
16
+ bold: chalk.bold,
17
+ flag: chalk.magentaBright,
18
+ };
19
+
20
+ const BANNER = [
21
+ '',
22
+ c.accent(' ____ _ _ _ _ '),
23
+ c.accent(' / ___| _| |__ ___ _ __| | | |_ _| |__ '),
24
+ c.accent(" | | | | | | '_ \\ / _ \\ '__| |_| | | | | '_ \\ "),
25
+ c.accent(' | |__| |_| | |_) | __/ | | _ | |_| | |_) |'),
26
+ c.accent(' \\____\\__, |_.__/ \\___|_| |_| |_|\\__,_|_.__/ '),
27
+ c.accent(' |___/ ') + c.dim('practice environment'),
28
+ '',
29
+ c.title(' CyberHub Practice Environment'),
30
+ c.dim(' A beginner-friendly, terminal-based CTF trainer'),
31
+ c.dim(' in the ICOA terminal style'),
32
+ '',
33
+ ].join('\n');
34
+
35
+ function welcomeScreen() {
36
+ return [
37
+ BANNER,
38
+ c.dim(' ──────────────────────────────────────────────'),
39
+ ' ' + c.bold('Welcome, operative.'),
40
+ '',
41
+ ' This is a self-contained practice range. Solve challenges,',
42
+ ' submit flags, and track your progress — all from this terminal.',
43
+ '',
44
+ ' ' + c.accent('Press ENTER to begin') + c.dim(' (Ctrl+C to quit)'),
45
+ '',
46
+ ].join('\n');
47
+ }
48
+
49
+ // Render an array of row objects as an aligned text table.
50
+ // columns: [{ key, label, color? }]
51
+ function table(rows, columns) {
52
+ const widths = columns.map((col) => {
53
+ const headerLen = String(col.label).length;
54
+ const cellLen = rows.reduce(
55
+ (max, row) => Math.max(max, String(row[col.key] == null ? '' : row[col.key]).length),
56
+ 0
57
+ );
58
+ return Math.max(headerLen, cellLen);
59
+ });
60
+
61
+ const pad = (str, width) => {
62
+ const s = String(str == null ? '' : str);
63
+ return s + ' '.repeat(Math.max(0, width - s.length));
64
+ };
65
+
66
+ const header = columns
67
+ .map((col, i) => c.bold(pad(col.label, widths[i])))
68
+ .join(' ');
69
+ const sep = c.dim(widths.map((w) => '─'.repeat(w)).join(' '));
70
+
71
+ const body = rows.map((row) =>
72
+ columns
73
+ .map((col, i) => {
74
+ const text = pad(row[col.key], widths[i]);
75
+ return col.color ? col.color(text) : text;
76
+ })
77
+ .join(' ')
78
+ );
79
+
80
+ return [header, sep, ...body].join('\n');
81
+ }
82
+
83
+ function rankFor(points) {
84
+ if (points >= 1000) return 'Elite';
85
+ if (points >= 600) return 'Operator';
86
+ if (points >= 300) return 'Pentester';
87
+ if (points >= 100) return 'Script Kiddie';
88
+ return 'Recruit';
89
+ }
90
+
91
+ module.exports = {
92
+ c,
93
+ BANNER,
94
+ welcomeScreen,
95
+ table,
96
+ rankFor,
97
+ };