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.
@@ -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
- this.users.set("root", this.createRecord("root", this.defaultRootPassword));
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
- await this.persist();
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 content = Array.from(this.users.values())
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
- this.vfs.writeFile(
514
- this.quotasPath,
515
- quotasContent.length > 0 ? `${quotasContent}\n` : "",
516
- { mode: 0o600 },
517
- );
518
- await this.vfs.flushMirror();
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
- return {
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
- return scryptSync(password, salt, 64).toString("hex");
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
+ }