typescript-virtual-container 1.2.3 → 1.2.5

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.
Files changed (69) hide show
  1. package/README.md +871 -1231
  2. package/benchmark-results.txt +21 -21
  3. package/biome.json +9 -0
  4. package/dist/SSHMimic/index.d.ts +19 -2
  5. package/dist/SSHMimic/index.d.ts.map +1 -1
  6. package/dist/SSHMimic/index.js +127 -15
  7. package/dist/VirtualFileSystem/index.d.ts +115 -88
  8. package/dist/VirtualFileSystem/index.d.ts.map +1 -1
  9. package/dist/VirtualFileSystem/index.js +406 -258
  10. package/dist/VirtualShell/index.d.ts +3 -4
  11. package/dist/VirtualShell/index.d.ts.map +1 -1
  12. package/dist/VirtualShell/index.js +5 -23
  13. package/dist/VirtualUserManager/index.d.ts +41 -3
  14. package/dist/VirtualUserManager/index.d.ts.map +1 -1
  15. package/dist/VirtualUserManager/index.js +83 -21
  16. package/dist/commands/chmod.d.ts +3 -0
  17. package/dist/commands/chmod.d.ts.map +1 -0
  18. package/dist/commands/chmod.js +31 -0
  19. package/dist/commands/cp.d.ts +3 -0
  20. package/dist/commands/cp.d.ts.map +1 -0
  21. package/dist/commands/cp.js +68 -0
  22. package/dist/commands/find.d.ts +3 -0
  23. package/dist/commands/find.d.ts.map +1 -0
  24. package/dist/commands/find.js +48 -0
  25. package/dist/commands/grep.d.ts.map +1 -1
  26. package/dist/commands/grep.js +61 -35
  27. package/dist/commands/head.d.ts +3 -0
  28. package/dist/commands/head.d.ts.map +1 -0
  29. package/dist/commands/head.js +30 -0
  30. package/dist/commands/index.d.ts.map +1 -1
  31. package/dist/commands/index.js +25 -35
  32. package/dist/commands/ln.d.ts +3 -0
  33. package/dist/commands/ln.d.ts.map +1 -0
  34. package/dist/commands/ln.js +42 -0
  35. package/dist/commands/mv.d.ts +3 -0
  36. package/dist/commands/mv.d.ts.map +1 -0
  37. package/dist/commands/mv.js +35 -0
  38. package/dist/commands/tail.d.ts +3 -0
  39. package/dist/commands/tail.d.ts.map +1 -0
  40. package/dist/commands/tail.js +33 -0
  41. package/dist/commands/wc.d.ts +3 -0
  42. package/dist/commands/wc.d.ts.map +1 -0
  43. package/dist/commands/wc.js +48 -0
  44. package/dist/index.d.ts +1 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/standalone.js +7 -9
  47. package/package.json +7 -3
  48. package/scripts/publish-package.sh +70 -0
  49. package/src/SSHMimic/index.ts +159 -17
  50. package/src/VirtualFileSystem/index.ts +500 -280
  51. package/src/VirtualShell/index.ts +5 -33
  52. package/src/VirtualUserManager/index.ts +92 -26
  53. package/src/commands/chmod.ts +33 -0
  54. package/src/commands/cp.ts +76 -0
  55. package/src/commands/find.ts +61 -0
  56. package/src/commands/grep.ts +54 -38
  57. package/src/commands/head.ts +35 -0
  58. package/src/commands/index.ts +25 -43
  59. package/src/commands/ln.ts +47 -0
  60. package/src/commands/mv.ts +43 -0
  61. package/src/commands/tail.ts +37 -0
  62. package/src/commands/wc.ts +48 -0
  63. package/src/index.ts +1 -0
  64. package/src/standalone.ts +12 -9
  65. package/standalone.js +102 -0
  66. package/standalone.js.map +7 -0
  67. package/tests/bun-test-shim.ts +1 -0
  68. package/tests/sftp.test.ts +115 -191
  69. package/tests/users.test.ts +66 -83
@@ -11,14 +11,31 @@ import { loadOrCreateHostKey } from "./hostKey";
11
11
  * This class is exported as `VirtualSshServer` for public API compatibility.
12
12
  * Create an instance, call {@link SshMimic.start}, and stop it with
13
13
  * {@link SshMimic.stop} when your process exits.
14
+ *
15
+ * Features:
16
+ * - Password authentication
17
+ * - Public-key authentication
18
+ * - Per-IP rate limiting / lockout for brute-force protection
19
+ * - Interactive shell sessions
20
+ * - Non-interactive exec sessions
14
21
  */
15
22
  const perf: PerfLogger = createPerfLogger("SshMimic");
16
23
 
24
+ interface RateLimitEntry {
25
+ attempts: number;
26
+ lockedUntil: number;
27
+ }
28
+
17
29
  class SshMimic extends EventEmitter {
18
30
  port: number;
19
31
  server: SshServer | null;
20
32
  private shell: VirtualShell;
21
- private shellHostname: string;
33
+
34
+ /** Max failed auth attempts before an IP is temporarily locked. */
35
+ private readonly maxAuthAttempts: number;
36
+ /** How long (ms) a locked IP must wait before retrying. */
37
+ private readonly lockoutDurationMs: number;
38
+ private readonly authAttempts = new Map<string, RateLimitEntry>();
22
39
 
23
40
  /**
24
41
  * Creates a new SSH mimic server instance.
@@ -26,24 +43,73 @@ class SshMimic extends EventEmitter {
26
43
  * @param port TCP port to bind on localhost.
27
44
  * @param hostname Virtual hostname used for the SSH ident and default shell label.
28
45
  * @param shell Optional preconfigured virtual shell instance to reuse.
46
+ * @param maxAuthAttempts Max failed attempts per IP before lockout (default: 5).
47
+ * @param lockoutDurationMs Lockout window in ms after exceeding attempts (default: 60 000).
29
48
  */
30
49
  constructor({
31
50
  port,
32
51
  hostname = "typescript-vm",
33
52
  shell = new VirtualShell(hostname),
53
+ maxAuthAttempts = 5,
54
+ lockoutDurationMs = 60_000,
34
55
  }: {
35
56
  port: number;
36
57
  hostname?: string;
37
58
  shell?: VirtualShell;
59
+ maxAuthAttempts?: number;
60
+ lockoutDurationMs?: number;
38
61
  }) {
39
62
  super();
40
63
  perf.mark("constructor");
41
64
  this.port = port;
42
- this.shellHostname = hostname;
43
65
  this.server = null;
44
66
  this.shell = shell;
67
+ this.maxAuthAttempts = maxAuthAttempts;
68
+ this.lockoutDurationMs = lockoutDurationMs;
69
+ }
70
+
71
+ // ── Rate limiting ────────────────────────────────────────────────────────
72
+
73
+ private isLockedOut(ip: string): boolean {
74
+ const entry = this.authAttempts.get(ip);
75
+ if (!entry) return false;
76
+ if (Date.now() < entry.lockedUntil) return true;
77
+ if (entry.lockedUntil > 0) {
78
+ this.authAttempts.delete(ip);
79
+ }
80
+ return false;
81
+ }
82
+
83
+ private recordFailure(ip: string): void {
84
+ const entry = this.authAttempts.get(ip) ?? { attempts: 0, lockedUntil: 0 };
85
+ entry.attempts += 1;
86
+ if (entry.attempts >= this.maxAuthAttempts) {
87
+ entry.lockedUntil = Date.now() + this.lockoutDurationMs;
88
+ this.emit("auth:lockout", { ip, until: new Date(entry.lockedUntil) });
89
+ }
90
+ this.authAttempts.set(ip, entry);
45
91
  }
46
92
 
93
+ private recordSuccess(ip: string): void {
94
+ this.authAttempts.delete(ip);
95
+ }
96
+
97
+ // ── Home directory bootstrap ─────────────────────────────────────────────
98
+
99
+ private ensureHomeDir(authUser: string): void {
100
+ const homePath = `/home/${authUser}`;
101
+ if (!this.shell.vfs.exists(homePath)) {
102
+ this.shell.vfs.mkdir(homePath, 0o755);
103
+ this.shell.vfs.writeFile(
104
+ `${homePath}/README.txt`,
105
+ `Welcome to ${this.shell.hostname}\n`,
106
+ );
107
+ void this.shell.vfs.flushMirror();
108
+ }
109
+ }
110
+
111
+ // ── Server lifecycle ─────────────────────────────────────────────────────
112
+
47
113
  /**
48
114
  * Starts server and initializes virtual filesystem, users, and handlers.
49
115
  *
@@ -54,7 +120,6 @@ class SshMimic extends EventEmitter {
54
120
  const shell = this.shell;
55
121
  const privateKey = loadOrCreateHostKey();
56
122
 
57
- // Ensure VirtualShell is fully initialized before accepting connections
58
123
  await shell.ensureInitialized();
59
124
 
60
125
  this.server = new SshServer(
@@ -70,14 +135,44 @@ class SshMimic extends EventEmitter {
70
135
  this.emit("client:connect");
71
136
 
72
137
  client.on("authentication", (ctx) => {
73
- shell;
138
+ const candidateUser = ctx.username || "root";
139
+ remoteAddress = (ctx as { ip?: string }).ip ?? remoteAddress;
140
+
141
+ // Rate-limit check
142
+ if (this.isLockedOut(remoteAddress)) {
143
+ this.emit("auth:failure", {
144
+ username: candidateUser,
145
+ remoteAddress,
146
+ reason: "lockout",
147
+ });
148
+ ctx.reject();
149
+ return;
150
+ }
151
+
152
+ // ── Password auth ──────────────────────────────────────
74
153
  if (ctx.method === "password") {
75
- const candidateUser = ctx.username || "root";
76
- remoteAddress = (ctx as { ip?: string }).ip ?? remoteAddress;
154
+ if (!shell.users.hasPassword(candidateUser)) {
155
+ console.log(
156
+ `User ${candidateUser} has no password set, allowing login without verification`,
157
+ );
158
+ authUser = candidateUser;
159
+ sessionId = shell.users.registerSession(
160
+ authUser,
161
+ remoteAddress,
162
+ ).id;
163
+ this.recordSuccess(remoteAddress);
164
+ this.emit("auth:success", { username: authUser, remoteAddress });
165
+ this.ensureHomeDir(authUser);
166
+ ctx.accept();
167
+ return;
168
+ }
77
169
 
78
170
  if (
79
- !shell.users.verifyPassword(candidateUser, ctx.password ?? "")
171
+ !ctx.password ||
172
+ ctx.password === "" ||
173
+ !shell.users.verifyPassword(candidateUser, ctx.password)
80
174
  ) {
175
+ this.recordFailure(remoteAddress);
81
176
  this.emit("auth:failure", {
82
177
  username: candidateUser,
83
178
  remoteAddress,
@@ -88,23 +183,62 @@ class SshMimic extends EventEmitter {
88
183
 
89
184
  authUser = candidateUser;
90
185
  sessionId = shell.users.registerSession(authUser, remoteAddress).id;
186
+ this.recordSuccess(remoteAddress);
91
187
  this.emit("auth:success", { username: authUser, remoteAddress });
188
+ this.ensureHomeDir(authUser);
189
+ ctx.accept();
190
+ return;
191
+ }
92
192
 
93
- const homePath = `/home/${authUser}`;
94
- if (!shell.vfs.exists(homePath)) {
95
- shell.vfs.mkdir(homePath, 0o755);
96
- shell.vfs.writeFile(
97
- `${homePath}/README.txt`,
98
- `Welcome to ${shell?.hostname ?? this.shellHostname}`,
99
- );
100
- void shell.vfs.flushMirror();
193
+ // ── Public-key auth ────────────────────────────────────
194
+ if (ctx.method === "publickey") {
195
+ const authorizedKeys = shell.users.getAuthorizedKeys(candidateUser);
196
+ if (authorizedKeys.length === 0) {
197
+ // No keys configured — reject cleanly
198
+ ctx.reject();
199
+ return;
101
200
  }
102
201
 
103
- ctx.accept();
202
+ const incomingKey = ctx.key;
203
+ const keyMatches = authorizedKeys.some(
204
+ (k) =>
205
+ k.algo === incomingKey.algo && k.data.equals(incomingKey.data),
206
+ );
207
+
208
+ if (!keyMatches) {
209
+ this.recordFailure(remoteAddress);
210
+ this.emit("auth:failure", {
211
+ username: candidateUser,
212
+ remoteAddress,
213
+ method: "publickey",
214
+ });
215
+ ctx.reject();
216
+ return;
217
+ }
218
+
219
+ // Key matched — if this is a signature check step, accept
220
+ if (ctx.signature) {
221
+ authUser = candidateUser;
222
+ sessionId = shell.users.registerSession(
223
+ authUser,
224
+ remoteAddress,
225
+ ).id;
226
+ this.recordSuccess(remoteAddress);
227
+ this.emit("auth:success", {
228
+ username: authUser,
229
+ remoteAddress,
230
+ method: "publickey",
231
+ });
232
+ this.ensureHomeDir(authUser);
233
+ ctx.accept();
234
+ } else {
235
+ // Key exists but no signature yet — ssh2 will call again with signature
236
+ ctx.accept();
237
+ }
104
238
  return;
105
239
  }
106
240
 
107
- ctx.reject();
241
+ ctx.reject(["password", "publickey"]);
108
242
  });
109
243
 
110
244
  client.on("close", () => {
@@ -182,6 +316,14 @@ class SshMimic extends EventEmitter {
182
316
  });
183
317
  }
184
318
  }
319
+
320
+ /**
321
+ * Manually clears the rate-limit record for an IP address.
322
+ * Useful in tests or admin tooling.
323
+ */
324
+ public clearLockout(ip: string): void {
325
+ this.authAttempts.delete(ip);
326
+ }
185
327
  }
186
328
 
187
329
  export { SftpMimic } from "./sftp";