typescript-virtual-container 1.2.4 → 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 (66) hide show
  1. package/README.md +868 -1245
  2. package/benchmark-results.txt +21 -21
  3. package/dist/SSHMimic/index.d.ts +19 -2
  4. package/dist/SSHMimic/index.d.ts.map +1 -1
  5. package/dist/SSHMimic/index.js +116 -20
  6. package/dist/VirtualFileSystem/index.d.ts +115 -88
  7. package/dist/VirtualFileSystem/index.d.ts.map +1 -1
  8. package/dist/VirtualFileSystem/index.js +406 -258
  9. package/dist/VirtualShell/index.d.ts +3 -4
  10. package/dist/VirtualShell/index.d.ts.map +1 -1
  11. package/dist/VirtualShell/index.js +4 -6
  12. package/dist/VirtualUserManager/index.d.ts +25 -0
  13. package/dist/VirtualUserManager/index.d.ts.map +1 -1
  14. package/dist/VirtualUserManager/index.js +33 -0
  15. package/dist/commands/chmod.d.ts +3 -0
  16. package/dist/commands/chmod.d.ts.map +1 -0
  17. package/dist/commands/chmod.js +31 -0
  18. package/dist/commands/cp.d.ts +3 -0
  19. package/dist/commands/cp.d.ts.map +1 -0
  20. package/dist/commands/cp.js +68 -0
  21. package/dist/commands/find.d.ts +3 -0
  22. package/dist/commands/find.d.ts.map +1 -0
  23. package/dist/commands/find.js +48 -0
  24. package/dist/commands/grep.d.ts.map +1 -1
  25. package/dist/commands/grep.js +61 -35
  26. package/dist/commands/head.d.ts +3 -0
  27. package/dist/commands/head.d.ts.map +1 -0
  28. package/dist/commands/head.js +30 -0
  29. package/dist/commands/index.d.ts.map +1 -1
  30. package/dist/commands/index.js +25 -35
  31. package/dist/commands/ln.d.ts +3 -0
  32. package/dist/commands/ln.d.ts.map +1 -0
  33. package/dist/commands/ln.js +42 -0
  34. package/dist/commands/mv.d.ts +3 -0
  35. package/dist/commands/mv.d.ts.map +1 -0
  36. package/dist/commands/mv.js +35 -0
  37. package/dist/commands/tail.d.ts +3 -0
  38. package/dist/commands/tail.d.ts.map +1 -0
  39. package/dist/commands/tail.js +33 -0
  40. package/dist/commands/wc.d.ts +3 -0
  41. package/dist/commands/wc.d.ts.map +1 -0
  42. package/dist/commands/wc.js +48 -0
  43. package/dist/index.d.ts +1 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/package.json +5 -2
  46. package/scripts/publish-package.sh +70 -0
  47. package/src/SSHMimic/index.ts +143 -28
  48. package/src/VirtualFileSystem/index.ts +500 -280
  49. package/src/VirtualShell/index.ts +4 -6
  50. package/src/VirtualUserManager/index.ts +41 -0
  51. package/src/commands/chmod.ts +33 -0
  52. package/src/commands/cp.ts +76 -0
  53. package/src/commands/find.ts +61 -0
  54. package/src/commands/grep.ts +54 -38
  55. package/src/commands/head.ts +35 -0
  56. package/src/commands/index.ts +25 -43
  57. package/src/commands/ln.ts +47 -0
  58. package/src/commands/mv.ts +43 -0
  59. package/src/commands/tail.ts +37 -0
  60. package/src/commands/wc.ts +48 -0
  61. package/src/index.ts +1 -0
  62. package/standalone.js +62 -52
  63. package/standalone.js.map +4 -4
  64. package/tests/bun-test-shim.ts +1 -0
  65. package/tests/sftp.test.ts +115 -191
  66. 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);
91
+ }
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
+ }
45
109
  }
46
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,11 +135,22 @@ class SshMimic extends EventEmitter {
70
135
  this.emit("client:connect");
71
136
 
72
137
  client.on("authentication", (ctx) => {
73
- shell;
74
- if (ctx.method === "password") {
75
- const candidateUser = ctx.username || "root";
76
- remoteAddress = (ctx as { ip?: string }).ip ?? remoteAddress;
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
+ }
77
151
 
152
+ // ── Password auth ──────────────────────────────────────
153
+ if (ctx.method === "password") {
78
154
  if (!shell.users.hasPassword(candidateUser)) {
79
155
  console.log(
80
156
  `User ${candidateUser} has no password set, allowing login without verification`,
@@ -84,18 +160,9 @@ class SshMimic extends EventEmitter {
84
160
  authUser,
85
161
  remoteAddress,
86
162
  ).id;
163
+ this.recordSuccess(remoteAddress);
87
164
  this.emit("auth:success", { username: authUser, remoteAddress });
88
-
89
- const homePath = `/home/${authUser}`;
90
- if (!shell.vfs.exists(homePath)) {
91
- shell.vfs.mkdir(homePath, 0o755);
92
- shell.vfs.writeFile(
93
- `${homePath}/README.txt`,
94
- `Welcome to ${shell?.hostname ?? this.shellHostname}`,
95
- );
96
- void shell.vfs.flushMirror();
97
- }
98
-
165
+ this.ensureHomeDir(authUser);
99
166
  ctx.accept();
100
167
  return;
101
168
  }
@@ -105,6 +172,7 @@ class SshMimic extends EventEmitter {
105
172
  ctx.password === "" ||
106
173
  !shell.users.verifyPassword(candidateUser, ctx.password)
107
174
  ) {
175
+ this.recordFailure(remoteAddress);
108
176
  this.emit("auth:failure", {
109
177
  username: candidateUser,
110
178
  remoteAddress,
@@ -115,23 +183,62 @@ class SshMimic extends EventEmitter {
115
183
 
116
184
  authUser = candidateUser;
117
185
  sessionId = shell.users.registerSession(authUser, remoteAddress).id;
186
+ this.recordSuccess(remoteAddress);
118
187
  this.emit("auth:success", { username: authUser, remoteAddress });
188
+ this.ensureHomeDir(authUser);
189
+ ctx.accept();
190
+ return;
191
+ }
119
192
 
120
- const homePath = `/home/${authUser}`;
121
- if (!shell.vfs.exists(homePath)) {
122
- shell.vfs.mkdir(homePath, 0o755);
123
- shell.vfs.writeFile(
124
- `${homePath}/README.txt`,
125
- `Welcome to ${shell?.hostname ?? this.shellHostname}`,
126
- );
127
- 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;
128
200
  }
129
201
 
130
- 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
+ }
131
238
  return;
132
239
  }
133
240
 
134
- ctx.reject();
241
+ ctx.reject(["password", "publickey"]);
135
242
  });
136
243
 
137
244
  client.on("close", () => {
@@ -209,6 +316,14 @@ class SshMimic extends EventEmitter {
209
316
  });
210
317
  }
211
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
+ }
212
327
  }
213
328
 
214
329
  export { SftpMimic } from "./sftp";