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/LICENSE +21 -0
- package/README.md +102 -0
- package/bin/cyberhub.js +12 -0
- package/data/challenges/crypto-101.yml +16 -0
- package/data/challenges/files/crypto-101/message.txt +5 -0
- package/data/challenges/files/forensics-101/access.log +7 -0
- package/data/challenges/files/web-101/cookies.txt +11 -0
- package/data/challenges/forensics-101.yml +14 -0
- package/data/challenges/web-101.yml +16 -0
- package/package.json +37 -0
- package/src/app.js +85 -0
- package/src/auth.js +42 -0
- package/src/commands.js +442 -0
- package/src/input.js +59 -0
- package/src/repl.js +71 -0
- package/src/store.js +337 -0
- package/src/ui.js +97 -0
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
|
+
};
|