typescript-virtual-container 1.1.7 → 1.2.1
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/benchmark-results.txt +40 -0
- package/benchmark-virtualshell.ts +96 -0
- package/dist/Honeypot/index.d.ts.map +1 -1
- package/dist/Honeypot/index.js +9 -0
- package/dist/SSHClient/index.d.ts +0 -14
- package/dist/SSHClient/index.d.ts.map +1 -1
- package/dist/SSHClient/index.js +19 -0
- package/dist/SSHMimic/index.d.ts +0 -7
- package/dist/SSHMimic/index.d.ts.map +1 -1
- package/dist/SSHMimic/index.js +5 -0
- package/dist/SSHMimic/sftp.d.ts.map +1 -1
- package/dist/SSHMimic/sftp.js +5 -0
- package/dist/VirtualFileSystem/index.d.ts +0 -7
- package/dist/VirtualFileSystem/index.d.ts.map +1 -1
- package/dist/VirtualFileSystem/index.js +18 -0
- package/dist/VirtualShell/index.d.ts.map +1 -1
- package/dist/VirtualShell/index.js +14 -1
- package/dist/VirtualUserManager/index.d.ts +4 -1
- package/dist/VirtualUserManager/index.d.ts.map +1 -1
- package/dist/VirtualUserManager/index.js +74 -14
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -5
- package/dist/utils/perfLogger.d.ts +9 -0
- package/dist/utils/perfLogger.d.ts.map +1 -0
- package/dist/utils/perfLogger.js +49 -0
- package/package.json +1 -1
- package/src/Honeypot/index.ts +11 -0
- package/src/SSHClient/index.ts +23 -1
- package/src/SSHMimic/index.ts +6 -0
- package/src/SSHMimic/sftp.ts +8 -1
- package/src/VirtualFileSystem/index.ts +20 -0
- package/src/VirtualShell/index.ts +18 -1
- package/src/VirtualUserManager/index.ts +103 -26
- package/src/index.ts +6 -6
- package/src/utils/perfLogger.ts +72 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { randomBytes, randomUUID, scryptSync } from "node:crypto";
|
|
1
|
+
import { createHash, randomBytes, randomUUID, scryptSync } from "node:crypto";
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import type { PerfLogger } from "../utils/perfLogger";
|
|
5
|
+
import { createPerfLogger } from "../utils/perfLogger";
|
|
4
6
|
import type VirtualFileSystem from "../VirtualFileSystem";
|
|
5
7
|
|
|
6
8
|
/** Persisted virtual user credential record. */
|
|
@@ -27,12 +29,24 @@ export interface VirtualActiveSession {
|
|
|
27
29
|
startedAt: string;
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
function resolveFastPasswordHash(): boolean {
|
|
33
|
+
const configured = process.env.SSH_MIMIC_FAST_PASSWORD_HASH;
|
|
34
|
+
return (
|
|
35
|
+
!!configured &&
|
|
36
|
+
!["0", "false", "no", "off"].includes(configured.toLowerCase())
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const perf: PerfLogger = createPerfLogger("VirtualUserManager");
|
|
41
|
+
|
|
30
42
|
/**
|
|
31
43
|
* Persistent user, sudoers, and active-session manager for the shell runtime.
|
|
32
44
|
*
|
|
33
|
-
* Passwords are hashed with scrypt and stored in the backing virtual filesystem.
|
|
45
|
+
* Passwords are hashed with scrypt by default and stored in the backing virtual filesystem.
|
|
34
46
|
*/
|
|
35
47
|
export class VirtualUserManager extends EventEmitter {
|
|
48
|
+
private static readonly recordCache = new Map<string, VirtualUserRecord>();
|
|
49
|
+
private static readonly fastPasswordHash = resolveFastPasswordHash();
|
|
36
50
|
private readonly usersPath = "/virtual-env-js/.auth/htpasswd";
|
|
37
51
|
private readonly sudoersPath = "/virtual-env-js/.auth/sudoers";
|
|
38
52
|
private readonly quotasPath = "/virtual-env-js/.auth/quotas";
|
|
@@ -56,6 +70,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
56
70
|
private readonly autoSudoForNewUsers: boolean = true,
|
|
57
71
|
) {
|
|
58
72
|
super();
|
|
73
|
+
perf.mark("constructor");
|
|
59
74
|
}
|
|
60
75
|
|
|
61
76
|
/**
|
|
@@ -63,23 +78,30 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
63
78
|
* Also creates the current system user if not already present.
|
|
64
79
|
*/
|
|
65
80
|
public async initialize(): Promise<void> {
|
|
81
|
+
perf.mark("initialize");
|
|
66
82
|
this.loadFromVfs();
|
|
67
83
|
this.loadSudoersFromVfs();
|
|
68
84
|
this.loadQuotasFromVfs();
|
|
69
85
|
|
|
70
|
-
|
|
86
|
+
let changed = false;
|
|
87
|
+
if (!this.users.has("root")) {
|
|
88
|
+
this.users.set(
|
|
89
|
+
"root",
|
|
90
|
+
this.createRecord("root", this.defaultRootPassword),
|
|
91
|
+
);
|
|
92
|
+
changed = true;
|
|
93
|
+
}
|
|
71
94
|
|
|
72
95
|
this.sudoers.add("root");
|
|
73
96
|
|
|
74
97
|
// Auto-create current system user for easier authentication
|
|
75
98
|
const currentUser = process.env.USER || process.env.USERNAME;
|
|
76
99
|
if (currentUser && currentUser !== "root" && !this.users.has(currentUser)) {
|
|
77
|
-
// Use same password as root for convenience, or a generic default
|
|
78
100
|
const userPassword = this.defaultRootPassword;
|
|
79
101
|
this.users.set(currentUser, this.createRecord(currentUser, userPassword));
|
|
80
102
|
this.sudoers.add(currentUser);
|
|
103
|
+
changed = true;
|
|
81
104
|
|
|
82
|
-
// Create home directory for the system user
|
|
83
105
|
const homePath = `/home/${currentUser}`;
|
|
84
106
|
if (!this.vfs.exists(homePath)) {
|
|
85
107
|
this.vfs.mkdir(homePath, 0o755);
|
|
@@ -90,7 +112,9 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
90
112
|
}
|
|
91
113
|
}
|
|
92
114
|
|
|
93
|
-
|
|
115
|
+
if (changed) {
|
|
116
|
+
await this.persist();
|
|
117
|
+
}
|
|
94
118
|
this.emit("initialized");
|
|
95
119
|
}
|
|
96
120
|
|
|
@@ -104,6 +128,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
104
128
|
username: string,
|
|
105
129
|
maxBytes: number,
|
|
106
130
|
): Promise<void> {
|
|
131
|
+
perf.mark("setQuotaBytes");
|
|
107
132
|
this.validateUsername(username);
|
|
108
133
|
if (!this.users.has(username)) {
|
|
109
134
|
throw new Error(`quota: user '${username}' does not exist`);
|
|
@@ -123,6 +148,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
123
148
|
* @param username Target username.
|
|
124
149
|
*/
|
|
125
150
|
public async clearQuota(username: string): Promise<void> {
|
|
151
|
+
perf.mark("clearQuota");
|
|
126
152
|
this.validateUsername(username);
|
|
127
153
|
this.quotas.delete(username);
|
|
128
154
|
await this.persist();
|
|
@@ -135,6 +161,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
135
161
|
* @returns Quota in bytes, or null when unlimited.
|
|
136
162
|
*/
|
|
137
163
|
public getQuotaBytes(username: string): number | null {
|
|
164
|
+
perf.mark("getQuotaBytes");
|
|
138
165
|
return this.quotas.get(username) ?? null;
|
|
139
166
|
}
|
|
140
167
|
|
|
@@ -145,6 +172,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
145
172
|
* @returns Current usage in bytes.
|
|
146
173
|
*/
|
|
147
174
|
public getUsageBytes(username: string): number {
|
|
175
|
+
perf.mark("getUsageBytes");
|
|
148
176
|
const homePath = `/home/${username}`;
|
|
149
177
|
if (!this.vfs.exists(homePath)) {
|
|
150
178
|
return 0;
|
|
@@ -167,6 +195,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
167
195
|
targetPath: string,
|
|
168
196
|
nextContent: string | Buffer,
|
|
169
197
|
): void {
|
|
198
|
+
perf.mark("assertWriteWithinQuota");
|
|
170
199
|
const quota = this.quotas.get(username);
|
|
171
200
|
if (quota === undefined) {
|
|
172
201
|
return;
|
|
@@ -209,6 +238,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
209
238
|
* @returns True when credentials are valid.
|
|
210
239
|
*/
|
|
211
240
|
public verifyPassword(username: string, password: string): boolean {
|
|
241
|
+
perf.mark("verifyPassword");
|
|
212
242
|
const record = this.users.get(username);
|
|
213
243
|
if (!record) {
|
|
214
244
|
return false;
|
|
@@ -224,6 +254,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
224
254
|
* @param password Initial plaintext password.
|
|
225
255
|
*/
|
|
226
256
|
public async addUser(username: string, password: string): Promise<void> {
|
|
257
|
+
perf.mark("addUser");
|
|
227
258
|
this.validateUsername(username);
|
|
228
259
|
this.validatePassword(password);
|
|
229
260
|
|
|
@@ -255,6 +286,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
255
286
|
* @param password New plaintext password.
|
|
256
287
|
*/
|
|
257
288
|
public async setPassword(username: string, password: string): Promise<void> {
|
|
289
|
+
perf.mark("setPassword");
|
|
258
290
|
this.validateUsername(username);
|
|
259
291
|
this.validatePassword(password);
|
|
260
292
|
|
|
@@ -272,6 +304,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
272
304
|
* @param username Username to remove.
|
|
273
305
|
*/
|
|
274
306
|
public async deleteUser(username: string): Promise<void> {
|
|
307
|
+
perf.mark("deleteUser");
|
|
275
308
|
this.validateUsername(username);
|
|
276
309
|
|
|
277
310
|
if (username === "root") {
|
|
@@ -295,6 +328,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
295
328
|
* @returns True when user can run sudo.
|
|
296
329
|
*/
|
|
297
330
|
public isSudoer(username: string): boolean {
|
|
331
|
+
perf.mark("isSudoer");
|
|
298
332
|
return this.sudoers.has(username);
|
|
299
333
|
}
|
|
300
334
|
|
|
@@ -304,6 +338,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
304
338
|
* @param username Username to promote.
|
|
305
339
|
*/
|
|
306
340
|
public async addSudoer(username: string): Promise<void> {
|
|
341
|
+
perf.mark("addSudoer");
|
|
307
342
|
this.validateUsername(username);
|
|
308
343
|
if (!this.users.has(username)) {
|
|
309
344
|
throw new Error(`sudoers: user '${username}' does not exist`);
|
|
@@ -319,6 +354,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
319
354
|
* @param username Username to demote.
|
|
320
355
|
*/
|
|
321
356
|
public async removeSudoer(username: string): Promise<void> {
|
|
357
|
+
perf.mark("removeSudoer");
|
|
322
358
|
this.validateUsername(username);
|
|
323
359
|
if (username === "root") {
|
|
324
360
|
throw new Error("sudoers: cannot remove root");
|
|
@@ -339,6 +375,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
339
375
|
username: string,
|
|
340
376
|
remoteAddress: string,
|
|
341
377
|
): VirtualActiveSession {
|
|
378
|
+
perf.mark("registerSession");
|
|
342
379
|
const session: VirtualActiveSession = {
|
|
343
380
|
id: randomUUID(),
|
|
344
381
|
username,
|
|
@@ -361,6 +398,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
361
398
|
* @param sessionId Session identifier; ignored when nullish.
|
|
362
399
|
*/
|
|
363
400
|
public unregisterSession(sessionId: string | null | undefined): void {
|
|
401
|
+
perf.mark("unregisterSession");
|
|
364
402
|
if (!sessionId) {
|
|
365
403
|
return;
|
|
366
404
|
}
|
|
@@ -388,6 +426,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
388
426
|
username: string,
|
|
389
427
|
remoteAddress: string,
|
|
390
428
|
): void {
|
|
429
|
+
perf.mark("updateSession");
|
|
391
430
|
if (!sessionId) {
|
|
392
431
|
return;
|
|
393
432
|
}
|
|
@@ -410,6 +449,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
410
449
|
* @returns Snapshot of active session descriptors.
|
|
411
450
|
*/
|
|
412
451
|
public listActiveSessions(): VirtualActiveSession[] {
|
|
452
|
+
perf.mark("listActiveSessions");
|
|
413
453
|
return Array.from(this.activeSessions.values()).sort((left, right) =>
|
|
414
454
|
left.startedAt.localeCompare(right.startedAt),
|
|
415
455
|
);
|
|
@@ -488,47 +528,84 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
488
528
|
this.vfs.mkdir(this.authDirPath, 0o700);
|
|
489
529
|
}
|
|
490
530
|
|
|
491
|
-
const
|
|
531
|
+
const authContent = Array.from(this.users.values())
|
|
492
532
|
.sort((left, right) => left.username.localeCompare(right.username))
|
|
493
533
|
.map((record) =>
|
|
494
534
|
[record.username, record.salt, record.passwordHash].join(":"),
|
|
495
535
|
)
|
|
496
536
|
.join("\n");
|
|
497
|
-
|
|
498
|
-
this.vfs.writeFile(
|
|
499
|
-
this.usersPath,
|
|
500
|
-
content.length > 0 ? `${content}\n` : "",
|
|
501
|
-
{ mode: 0o600 },
|
|
502
|
-
);
|
|
503
537
|
const sudoersContent = Array.from(this.sudoers.values()).sort().join("\n");
|
|
504
|
-
this.vfs.writeFile(
|
|
505
|
-
this.sudoersPath,
|
|
506
|
-
sudoersContent.length > 0 ? `${sudoersContent}\n` : "",
|
|
507
|
-
{ mode: 0o600 },
|
|
508
|
-
);
|
|
509
538
|
const quotasContent = Array.from(this.quotas.entries())
|
|
510
539
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
511
540
|
.map(([username, maxBytes]) => `${username}:${maxBytes}`)
|
|
512
541
|
.join("\n");
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
542
|
+
|
|
543
|
+
let changed = false;
|
|
544
|
+
changed =
|
|
545
|
+
this.writeIfChanged(
|
|
546
|
+
this.usersPath,
|
|
547
|
+
authContent.length > 0 ? `${authContent}\n` : "",
|
|
548
|
+
0o600,
|
|
549
|
+
) || changed;
|
|
550
|
+
changed =
|
|
551
|
+
this.writeIfChanged(
|
|
552
|
+
this.sudoersPath,
|
|
553
|
+
sudoersContent.length > 0 ? `${sudoersContent}\n` : "",
|
|
554
|
+
0o600,
|
|
555
|
+
) || changed;
|
|
556
|
+
changed =
|
|
557
|
+
this.writeIfChanged(
|
|
558
|
+
this.quotasPath,
|
|
559
|
+
quotasContent.length > 0 ? `${quotasContent}\n` : "",
|
|
560
|
+
0o600,
|
|
561
|
+
) || changed;
|
|
562
|
+
|
|
563
|
+
if (changed) {
|
|
564
|
+
await this.vfs.flushMirror();
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private writeIfChanged(
|
|
569
|
+
targetPath: string,
|
|
570
|
+
content: string,
|
|
571
|
+
mode: number,
|
|
572
|
+
): boolean {
|
|
573
|
+
if (this.vfs.exists(targetPath)) {
|
|
574
|
+
const existing = this.vfs.readFile(targetPath);
|
|
575
|
+
if (existing === content) {
|
|
576
|
+
this.vfs.chmod(targetPath, mode);
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
this.vfs.writeFile(targetPath, content, { mode });
|
|
582
|
+
return true;
|
|
519
583
|
}
|
|
520
584
|
|
|
521
585
|
private createRecord(username: string, password: string): VirtualUserRecord {
|
|
586
|
+
const cacheKey = `${username}:${password}`;
|
|
587
|
+
const cached = VirtualUserManager.recordCache.get(cacheKey);
|
|
588
|
+
if (cached) {
|
|
589
|
+
return cached;
|
|
590
|
+
}
|
|
591
|
+
|
|
522
592
|
const salt = randomBytes(16).toString("hex");
|
|
523
|
-
|
|
593
|
+
const record = {
|
|
524
594
|
username,
|
|
525
595
|
salt,
|
|
526
596
|
passwordHash: this.hashPassword(password, salt),
|
|
527
597
|
};
|
|
598
|
+
|
|
599
|
+
VirtualUserManager.recordCache.set(cacheKey, record);
|
|
600
|
+
return record;
|
|
528
601
|
}
|
|
529
602
|
|
|
530
603
|
private hashPassword(password: string, salt: string): string {
|
|
531
|
-
|
|
604
|
+
if (VirtualUserManager.fastPasswordHash) {
|
|
605
|
+
return createHash("sha256").update(`${salt}:${password}`).digest("hex");
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return scryptSync(password, salt, 32).toString("hex");
|
|
532
609
|
}
|
|
533
610
|
|
|
534
611
|
private validateUsername(username: string): void {
|
package/src/index.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { HoneyPot } from "./Honeypot";
|
|
2
|
-
import { SshClient } from "./SSHClient";
|
|
1
|
+
import { HoneyPot } from "./Honeypot/index";
|
|
2
|
+
import { SshClient } from "./SSHClient/index";
|
|
3
3
|
import { SftpMimic, SshMimic } from "./SSHMimic/index";
|
|
4
|
-
import VirtualFileSystem from "./VirtualFileSystem";
|
|
5
|
-
import { VirtualShell } from "./VirtualShell";
|
|
6
|
-
import { VirtualUserManager } from "./VirtualUserManager";
|
|
4
|
+
import VirtualFileSystem from "./VirtualFileSystem/index";
|
|
5
|
+
import { VirtualShell } from "./VirtualShell/index";
|
|
6
|
+
import { VirtualUserManager } from "./VirtualUserManager/index";
|
|
7
7
|
|
|
8
8
|
export type {
|
|
9
9
|
AuditLogEntry,
|
|
10
10
|
HoneyPotStats,
|
|
11
|
-
} from "./Honeypot";
|
|
11
|
+
} from "./Honeypot/index";
|
|
12
12
|
export type {
|
|
13
13
|
CommandContext,
|
|
14
14
|
CommandMode,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export type PerfLogger = {
|
|
2
|
+
enabled: boolean;
|
|
3
|
+
mark: (label: string) => void;
|
|
4
|
+
done: (label?: string) => void;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
function isTruthyEnv(value: string | undefined): boolean {
|
|
8
|
+
return value === "1" || value === "true";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function nowMs(): number {
|
|
12
|
+
if (
|
|
13
|
+
typeof performance !== "undefined" &&
|
|
14
|
+
typeof performance.now === "function"
|
|
15
|
+
) {
|
|
16
|
+
return performance.now();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return Date.now();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isPerfLoggingEnabled(): boolean {
|
|
23
|
+
return (
|
|
24
|
+
isTruthyEnv(process.env.DEV_MODE) || isTruthyEnv(process.env.RENDER_PERF)
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createPerfLogger(scope: string): PerfLogger {
|
|
29
|
+
const enabled = isPerfLoggingEnabled();
|
|
30
|
+
if (!enabled) {
|
|
31
|
+
return {
|
|
32
|
+
enabled,
|
|
33
|
+
mark: () => undefined,
|
|
34
|
+
done: () => undefined,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const startedAt = nowMs();
|
|
39
|
+
|
|
40
|
+
const mark = (label: string): void => {
|
|
41
|
+
const elapsedMs = nowMs() - startedAt;
|
|
42
|
+
console.log(`[perf][${scope}] ${label}: ${elapsedMs.toFixed(1)}ms`);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const done = (label = "done"): void => {
|
|
46
|
+
mark(label);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
enabled,
|
|
51
|
+
mark,
|
|
52
|
+
done,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function withPerf<T>(
|
|
57
|
+
scope: string,
|
|
58
|
+
label: string,
|
|
59
|
+
work: () => Promise<T>,
|
|
60
|
+
): Promise<T> {
|
|
61
|
+
const perf = createPerfLogger(scope);
|
|
62
|
+
if (!perf.enabled) {
|
|
63
|
+
return work();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
perf.mark(`${label}:start`);
|
|
67
|
+
try {
|
|
68
|
+
return await work();
|
|
69
|
+
} finally {
|
|
70
|
+
perf.done(`${label}:done`);
|
|
71
|
+
}
|
|
72
|
+
}
|