typescript-virtual-container 1.3.3 → 1.4.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.
Files changed (282) hide show
  1. package/.vscode/settings.json +0 -1
  2. package/README.md +674 -1504
  3. package/benchmark-results.txt +21 -21
  4. package/builds/self-standalone.js +274 -208
  5. package/builds/self-standalone.js.map +4 -4
  6. package/builds/standalone-wo-sftp.js +201 -149
  7. package/builds/standalone-wo-sftp.js.map +4 -4
  8. package/builds/standalone.js +263 -211
  9. package/builds/standalone.js.map +4 -4
  10. package/builds/web-full-api.min.js +3 -3
  11. package/builds/web-full-api.min.js.map +4 -4
  12. package/builds/web.min.js +2 -2
  13. package/builds/web.min.js.map +4 -4
  14. package/bun.lock +14 -12
  15. package/dist/SSHClient/index.d.ts.map +1 -1
  16. package/dist/SSHClient/index.js +5 -3
  17. package/dist/SSHMimic/executor.d.ts +1 -3
  18. package/dist/SSHMimic/executor.d.ts.map +1 -1
  19. package/dist/SSHMimic/executor.js +20 -22
  20. package/dist/SSHMimic/index.d.ts.map +1 -1
  21. package/dist/SSHMimic/index.js +5 -3
  22. package/dist/SSHMimic/sftp.d.ts.map +1 -1
  23. package/dist/SSHMimic/sftp.js +26 -21
  24. package/dist/VirtualShell/shell.d.ts.map +1 -1
  25. package/dist/VirtualShell/shell.js +25 -3
  26. package/dist/VirtualShell/shellParser.d.ts +1 -8
  27. package/dist/VirtualShell/shellParser.d.ts.map +1 -1
  28. package/dist/VirtualShell/shellParser.js +2 -81
  29. package/dist/VirtualUserManager/index.d.ts +7 -1
  30. package/dist/VirtualUserManager/index.d.ts.map +1 -1
  31. package/dist/VirtualUserManager/index.js +47 -19
  32. package/dist/commands/adduser.d.ts +10 -4
  33. package/dist/commands/adduser.d.ts.map +1 -1
  34. package/dist/commands/adduser.js +75 -12
  35. package/dist/commands/alias.d.ts +5 -0
  36. package/dist/commands/alias.d.ts.map +1 -1
  37. package/dist/commands/alias.js +5 -0
  38. package/dist/commands/apt.d.ts +5 -0
  39. package/dist/commands/apt.d.ts.map +1 -1
  40. package/dist/commands/apt.js +5 -0
  41. package/dist/commands/awk.d.ts +10 -8
  42. package/dist/commands/awk.d.ts.map +1 -1
  43. package/dist/commands/awk.js +156 -28
  44. package/dist/commands/cd.d.ts.map +1 -1
  45. package/dist/commands/cd.js +0 -3
  46. package/dist/commands/clear.d.ts +5 -0
  47. package/dist/commands/clear.d.ts.map +1 -1
  48. package/dist/commands/clear.js +5 -0
  49. package/dist/commands/command-helpers.d.ts.map +1 -1
  50. package/dist/commands/command-helpers.js +8 -0
  51. package/dist/commands/declare.d.ts +5 -0
  52. package/dist/commands/declare.d.ts.map +1 -1
  53. package/dist/commands/declare.js +5 -0
  54. package/dist/commands/deluser.d.ts +12 -0
  55. package/dist/commands/deluser.d.ts.map +1 -1
  56. package/dist/commands/deluser.js +72 -6
  57. package/dist/commands/df.d.ts +5 -0
  58. package/dist/commands/df.d.ts.map +1 -1
  59. package/dist/commands/df.js +5 -0
  60. package/dist/commands/du.d.ts +5 -0
  61. package/dist/commands/du.d.ts.map +1 -1
  62. package/dist/commands/du.js +5 -0
  63. package/dist/commands/export.d.ts +5 -0
  64. package/dist/commands/export.d.ts.map +1 -1
  65. package/dist/commands/export.js +5 -0
  66. package/dist/commands/grep.d.ts.map +1 -1
  67. package/dist/commands/grep.js +22 -4
  68. package/dist/commands/groups.d.ts +5 -0
  69. package/dist/commands/groups.d.ts.map +1 -1
  70. package/dist/commands/groups.js +5 -0
  71. package/dist/commands/gzip.d.ts +5 -2
  72. package/dist/commands/gzip.d.ts.map +1 -1
  73. package/dist/commands/gzip.js +48 -28
  74. package/dist/commands/head.d.ts.map +1 -1
  75. package/dist/commands/head.js +12 -3
  76. package/dist/commands/htop.d.ts +5 -0
  77. package/dist/commands/htop.d.ts.map +1 -1
  78. package/dist/commands/htop.js +5 -0
  79. package/dist/commands/kill.d.ts +5 -0
  80. package/dist/commands/kill.d.ts.map +1 -1
  81. package/dist/commands/kill.js +5 -0
  82. package/dist/commands/ln.d.ts +2 -0
  83. package/dist/commands/ln.d.ts.map +1 -1
  84. package/dist/commands/ln.js +22 -0
  85. package/dist/commands/ls.d.ts.map +1 -1
  86. package/dist/commands/ls.js +15 -0
  87. package/dist/commands/lsb-release.d.ts +5 -0
  88. package/dist/commands/lsb-release.d.ts.map +1 -1
  89. package/dist/commands/lsb-release.js +5 -0
  90. package/dist/commands/mkdir.d.ts +5 -0
  91. package/dist/commands/mkdir.d.ts.map +1 -1
  92. package/dist/commands/mkdir.js +5 -0
  93. package/dist/commands/mv.d.ts +5 -0
  94. package/dist/commands/mv.d.ts.map +1 -1
  95. package/dist/commands/mv.js +5 -0
  96. package/dist/commands/nano.d.ts +5 -0
  97. package/dist/commands/nano.d.ts.map +1 -1
  98. package/dist/commands/nano.js +5 -0
  99. package/dist/commands/neofetch.d.ts +5 -0
  100. package/dist/commands/neofetch.d.ts.map +1 -1
  101. package/dist/commands/neofetch.js +8 -5
  102. package/dist/commands/passwd.d.ts +8 -0
  103. package/dist/commands/passwd.d.ts.map +1 -1
  104. package/dist/commands/passwd.js +32 -11
  105. package/dist/commands/ping.d.ts +5 -0
  106. package/dist/commands/ping.d.ts.map +1 -1
  107. package/dist/commands/ping.js +5 -0
  108. package/dist/commands/printf.d.ts +5 -0
  109. package/dist/commands/printf.d.ts.map +1 -1
  110. package/dist/commands/printf.js +43 -12
  111. package/dist/commands/ps.d.ts +5 -0
  112. package/dist/commands/ps.d.ts.map +1 -1
  113. package/dist/commands/ps.js +5 -0
  114. package/dist/commands/read.d.ts +5 -0
  115. package/dist/commands/read.d.ts.map +1 -1
  116. package/dist/commands/read.js +5 -0
  117. package/dist/commands/registry.d.ts.map +1 -1
  118. package/dist/commands/registry.js +4 -1
  119. package/dist/commands/rm.d.ts +5 -0
  120. package/dist/commands/rm.d.ts.map +1 -1
  121. package/dist/commands/rm.js +5 -0
  122. package/dist/commands/runtime.d.ts.map +1 -1
  123. package/dist/commands/runtime.js +1 -57
  124. package/dist/commands/sed.d.ts +5 -0
  125. package/dist/commands/sed.d.ts.map +1 -1
  126. package/dist/commands/sed.js +5 -0
  127. package/dist/commands/set.d.ts +5 -6
  128. package/dist/commands/set.d.ts.map +1 -1
  129. package/dist/commands/set.js +5 -22
  130. package/dist/commands/sh.d.ts +6 -0
  131. package/dist/commands/sh.d.ts.map +1 -1
  132. package/dist/commands/sh.js +6 -0
  133. package/dist/commands/shift.d.ts +10 -0
  134. package/dist/commands/shift.d.ts.map +1 -1
  135. package/dist/commands/shift.js +10 -0
  136. package/dist/commands/sleep.d.ts +5 -0
  137. package/dist/commands/sleep.d.ts.map +1 -1
  138. package/dist/commands/sleep.js +5 -0
  139. package/dist/commands/sort.d.ts +5 -0
  140. package/dist/commands/sort.d.ts.map +1 -1
  141. package/dist/commands/sort.js +5 -0
  142. package/dist/commands/source.d.ts +5 -0
  143. package/dist/commands/source.d.ts.map +1 -1
  144. package/dist/commands/source.js +5 -0
  145. package/dist/commands/stat.d.ts +7 -0
  146. package/dist/commands/stat.d.ts.map +1 -0
  147. package/dist/commands/stat.js +56 -0
  148. package/dist/commands/su.d.ts +13 -0
  149. package/dist/commands/su.d.ts.map +1 -1
  150. package/dist/commands/su.js +45 -14
  151. package/dist/commands/sudo.d.ts.map +1 -1
  152. package/dist/commands/sudo.js +5 -0
  153. package/dist/commands/tail.d.ts +5 -0
  154. package/dist/commands/tail.d.ts.map +1 -1
  155. package/dist/commands/tail.js +15 -3
  156. package/dist/commands/tar.d.ts +5 -0
  157. package/dist/commands/tar.d.ts.map +1 -1
  158. package/dist/commands/tar.js +40 -10
  159. package/dist/commands/tee.d.ts +5 -0
  160. package/dist/commands/tee.d.ts.map +1 -1
  161. package/dist/commands/tee.js +5 -0
  162. package/dist/commands/touch.d.ts +5 -0
  163. package/dist/commands/touch.d.ts.map +1 -1
  164. package/dist/commands/touch.js +5 -0
  165. package/dist/commands/tr.d.ts.map +1 -1
  166. package/dist/commands/tr.js +45 -10
  167. package/dist/commands/tree.d.ts +5 -0
  168. package/dist/commands/tree.d.ts.map +1 -1
  169. package/dist/commands/tree.js +5 -0
  170. package/dist/commands/true.d.ts +10 -0
  171. package/dist/commands/true.d.ts.map +1 -1
  172. package/dist/commands/true.js +10 -0
  173. package/dist/commands/type.d.ts +5 -0
  174. package/dist/commands/type.d.ts.map +1 -1
  175. package/dist/commands/type.js +5 -0
  176. package/dist/commands/uname.d.ts +5 -0
  177. package/dist/commands/uname.d.ts.map +1 -1
  178. package/dist/commands/uname.js +5 -0
  179. package/dist/commands/uniq.d.ts +5 -0
  180. package/dist/commands/uniq.d.ts.map +1 -1
  181. package/dist/commands/uniq.js +5 -0
  182. package/dist/commands/unset.d.ts +5 -0
  183. package/dist/commands/unset.d.ts.map +1 -1
  184. package/dist/commands/unset.js +5 -0
  185. package/dist/commands/uptime.d.ts +5 -0
  186. package/dist/commands/uptime.d.ts.map +1 -1
  187. package/dist/commands/uptime.js +5 -0
  188. package/dist/commands/wc.d.ts +5 -0
  189. package/dist/commands/wc.d.ts.map +1 -1
  190. package/dist/commands/wc.js +5 -0
  191. package/dist/commands/wget.d.ts +5 -0
  192. package/dist/commands/wget.d.ts.map +1 -1
  193. package/dist/commands/wget.js +5 -0
  194. package/dist/commands/who.d.ts +5 -0
  195. package/dist/commands/who.d.ts.map +1 -1
  196. package/dist/commands/who.js +5 -0
  197. package/dist/commands/whoami.d.ts +5 -0
  198. package/dist/commands/whoami.d.ts.map +1 -1
  199. package/dist/commands/whoami.js +5 -0
  200. package/dist/commands/xargs.d.ts +5 -0
  201. package/dist/commands/xargs.d.ts.map +1 -1
  202. package/dist/commands/xargs.js +5 -0
  203. package/dist/self-standalone.js +254 -30
  204. package/dist/types/commands.d.ts +36 -0
  205. package/dist/types/commands.d.ts.map +1 -1
  206. package/dist/utils/tokenize.d.ts +20 -0
  207. package/dist/utils/tokenize.d.ts.map +1 -0
  208. package/dist/utils/tokenize.js +74 -0
  209. package/examples/web.min.js +2 -2
  210. package/package.json +1 -1
  211. package/src/SSHClient/index.ts +6 -3
  212. package/src/SSHMimic/executor.ts +21 -44
  213. package/src/SSHMimic/index.ts +7 -5
  214. package/src/SSHMimic/sftp.ts +28 -21
  215. package/src/VirtualShell/shell.ts +34 -4
  216. package/src/VirtualShell/shellParser.ts +2 -103
  217. package/src/VirtualUserManager/index.ts +44 -20
  218. package/src/commands/adduser.ts +86 -13
  219. package/src/commands/alias.ts +5 -0
  220. package/src/commands/apt.ts +5 -0
  221. package/src/commands/awk.ts +154 -29
  222. package/src/commands/cd.ts +0 -4
  223. package/src/commands/clear.ts +5 -0
  224. package/src/commands/command-helpers.ts +9 -0
  225. package/src/commands/declare.ts +5 -0
  226. package/src/commands/deluser.ts +84 -7
  227. package/src/commands/df.ts +5 -0
  228. package/src/commands/du.ts +5 -0
  229. package/src/commands/export.ts +5 -0
  230. package/src/commands/grep.ts +21 -8
  231. package/src/commands/groups.ts +5 -0
  232. package/src/commands/gzip.ts +54 -28
  233. package/src/commands/head.ts +14 -4
  234. package/src/commands/htop.ts +5 -0
  235. package/src/commands/kill.ts +5 -0
  236. package/src/commands/ln.ts +22 -0
  237. package/src/commands/ls.ts +17 -0
  238. package/src/commands/lsb-release.ts +5 -0
  239. package/src/commands/mkdir.ts +5 -0
  240. package/src/commands/mv.ts +5 -0
  241. package/src/commands/nano.ts +5 -0
  242. package/src/commands/neofetch.ts +8 -6
  243. package/src/commands/passwd.ts +35 -12
  244. package/src/commands/ping.ts +5 -0
  245. package/src/commands/printf.ts +30 -13
  246. package/src/commands/ps.ts +5 -0
  247. package/src/commands/read.ts +5 -0
  248. package/src/commands/registry.ts +4 -1
  249. package/src/commands/rm.ts +5 -0
  250. package/src/commands/runtime.ts +1 -61
  251. package/src/commands/sed.ts +5 -0
  252. package/src/commands/set.ts +5 -24
  253. package/src/commands/sh.ts +9 -3
  254. package/src/commands/shift.ts +10 -0
  255. package/src/commands/sleep.ts +5 -0
  256. package/src/commands/sort.ts +5 -0
  257. package/src/commands/source.ts +5 -0
  258. package/src/commands/stat.ts +61 -0
  259. package/src/commands/su.ts +54 -16
  260. package/src/commands/sudo.ts +5 -0
  261. package/src/commands/tail.ts +17 -3
  262. package/src/commands/tar.ts +38 -15
  263. package/src/commands/tee.ts +5 -0
  264. package/src/commands/touch.ts +5 -0
  265. package/src/commands/tr.ts +54 -10
  266. package/src/commands/tree.ts +5 -0
  267. package/src/commands/true.ts +10 -0
  268. package/src/commands/type.ts +5 -0
  269. package/src/commands/uname.ts +5 -0
  270. package/src/commands/uniq.ts +5 -0
  271. package/src/commands/unset.ts +5 -0
  272. package/src/commands/uptime.ts +5 -0
  273. package/src/commands/wc.ts +5 -0
  274. package/src/commands/wget.ts +5 -0
  275. package/src/commands/who.ts +5 -0
  276. package/src/commands/whoami.ts +5 -0
  277. package/src/commands/xargs.ts +5 -0
  278. package/src/self-standalone.ts +316 -33
  279. package/src/types/commands.ts +37 -0
  280. package/src/utils/tokenize.ts +78 -0
  281. package/builds/web-iife.min.js +0 -13
  282. package/builds/web-iife.min.js.map +0 -7
@@ -4,54 +4,13 @@ import type { CommandMode, CommandResult, ShellEnv } from "../types/commands";
4
4
  import type {
5
5
  Pipeline,
6
6
  PipelineCommand,
7
- Script,
8
7
  Statement,
9
8
  } from "../types/pipeline";
10
9
  import type { VirtualShell } from "../VirtualShell";
11
10
 
12
11
  // ── Script executor (handles &&/||/;) ────────────────────────────────────────
13
12
 
14
- export async function executeScript(
15
- script: Script,
16
- authUser: string,
17
- hostname: string,
18
- mode: CommandMode,
19
- cwd: string,
20
- shell: VirtualShell,
21
- env: ShellEnv,
22
- ): Promise<CommandResult> {
23
- if (!script.isValid)
24
- return { stderr: script.error || "Syntax error", exitCode: 1 };
25
-
26
- let lastResult: CommandResult = { exitCode: 0 };
27
13
 
28
- for (const stmt of script.statements) {
29
- // Decide whether to run this statement based on previous op
30
- lastResult = await executePipeline(
31
- stmt.pipeline,
32
- authUser,
33
- hostname,
34
- mode,
35
- cwd,
36
- shell,
37
- env,
38
- );
39
- env.lastExitCode = lastResult.exitCode ?? 0;
40
-
41
- // Propagate session-control signals
42
- if (
43
- lastResult.closeSession ||
44
- lastResult.switchUser ||
45
- lastResult.nextCwd
46
- ) {
47
- break;
48
- }
49
- }
50
-
51
- return lastResult;
52
- }
53
-
54
- /** Execute statements connected by &&/||/; */
55
14
  export async function executeStatements(
56
15
  statements: Statement[],
57
16
  authUser: string,
@@ -62,6 +21,8 @@ export async function executeStatements(
62
21
  env: ShellEnv,
63
22
  ): Promise<CommandResult> {
64
23
  let last: CommandResult = { exitCode: 0 };
24
+ const accumulatedStdout: string[] = [];
25
+ let currentCwd = cwd; // track cwd changes from cd, su, etc.
65
26
  let i = 0;
66
27
 
67
28
  while (i < statements.length) {
@@ -71,13 +32,26 @@ export async function executeStatements(
71
32
  authUser,
72
33
  hostname,
73
34
  mode,
74
- cwd,
35
+ currentCwd,
75
36
  shell,
76
37
  env,
77
38
  );
78
39
  env.lastExitCode = last.exitCode ?? 0;
79
40
 
80
- if (last.closeSession || last.switchUser) return last;
41
+ // Propagate cwd changes (cd, su -l, etc.)
42
+ if (last.nextCwd && (last.exitCode ?? 0) === 0) {
43
+ currentCwd = last.nextCwd;
44
+ }
45
+
46
+ // Collect stdout from each statement (for echo a; echo b → "a\nb\n")
47
+ if (last.stdout) accumulatedStdout.push(last.stdout);
48
+
49
+ if (last.closeSession || last.switchUser) {
50
+ return {
51
+ ...last,
52
+ stdout: accumulatedStdout.join("") || last.stdout,
53
+ };
54
+ }
81
55
 
82
56
  const op = stmt.op;
83
57
  if (!op || op === ";") {
@@ -95,7 +69,10 @@ export async function executeStatements(
95
69
  }
96
70
  i++;
97
71
  }
98
- return last;
72
+ // Merge accumulated stdout (for "echo a; echo b" → "a\nb\n")
73
+ const merged = accumulatedStdout.join("");
74
+ // Preserve the deepest cwd change across the whole pipeline
75
+ return { ...last, stdout: merged || last.stdout, nextCwd: currentCwd !== cwd ? currentCwd : undefined };
99
76
  }
100
77
 
101
78
  // ── Pipeline executor ─────────────────────────────────────────────────────────
@@ -21,6 +21,11 @@ import { loadOrCreateHostKey } from "./hostKey";
21
21
  */
22
22
  const perf: PerfLogger = createPerfLogger("SshMimic");
23
23
 
24
+ // ── Dev-mode logger ───────────────────────────────────────────────────────────
25
+ const DEV = !!process.env.DEV_MODE;
26
+ const devLog = DEV ? console.log.bind(console) : () => {};
27
+
28
+
24
29
  interface RateLimitEntry {
25
30
  attempts: number;
26
31
  lockedUntil: number;
@@ -152,9 +157,6 @@ class SshMimic extends EventEmitter {
152
157
  // ── Password auth ──────────────────────────────────────
153
158
  if (ctx.method === "password") {
154
159
  if (!shell.users.hasPassword(candidateUser)) {
155
- console.log(
156
- `User ${candidateUser} has no password set, allowing login without verification`,
157
- );
158
160
  authUser = candidateUser;
159
161
  sessionId = shell.users.registerSession(
160
162
  authUser,
@@ -297,7 +299,7 @@ class SshMimic extends EventEmitter {
297
299
  return new Promise<number>((resolve, reject) => {
298
300
  this.server?.once("error", (err: unknown) => reject(err));
299
301
  this.server?.listen(this.port, "0.0.0.0", () => {
300
- console.log(`SSH Mimic listening on port ${this.port}`);
302
+ devLog(`SSH Mimic listening on port ${this.port}`);
301
303
  this.emit("start", { port: this.port });
302
304
  resolve(this.port);
303
305
  });
@@ -311,7 +313,7 @@ class SshMimic extends EventEmitter {
311
313
  perf.mark("stop");
312
314
  if (this.server) {
313
315
  this.server.close(() => {
314
- console.log("SSH Mimic stopped");
316
+ devLog("SSH Mimic stopped");
315
317
  this.emit("stop");
316
318
  });
317
319
  }
@@ -10,6 +10,13 @@ import type VirtualFileSystem from "../VirtualFileSystem";
10
10
  import { VirtualShell } from "../VirtualShell";
11
11
  import type { VirtualUserManager } from "../VirtualUserManager";
12
12
  import { loadOrCreateHostKey } from "./hostKey";
13
+ // ── Dev-mode logger — silent in production ────────────────────────────────────
14
+ const DEV = !!process.env.DEV_MODE;
15
+ const devLog = DEV ? console.log.bind(console) : () => {};
16
+ const devWarn = DEV ? console.warn.bind(console) : () => {};
17
+ const devErr = DEV ? console.error.bind(console): () => {};
18
+
19
+
13
20
 
14
21
  const SFTP_STATUS_CODE = {
15
22
  OK: 0,
@@ -218,7 +225,7 @@ export class SftpMimic extends EventEmitter {
218
225
 
219
226
  // Add error handling for the client
220
227
  client.on("error", (error: unknown) => {
221
- console.error(`[SFTP] Client error:`, error);
228
+ devErr(`[SFTP] Client error:`, error);
222
229
  });
223
230
 
224
231
  const acceptSession = (username: string): void => {
@@ -248,7 +255,7 @@ export class SftpMimic extends EventEmitter {
248
255
  const candidateUser = ctx.username || "root";
249
256
  remoteAddress = (ctx as { ip?: string }).ip ?? remoteAddress;
250
257
 
251
- console.log(
258
+ devLog(
252
259
  `[SFTP] Auth attempt: user=${candidateUser}, method=${ctx.method}, ip=${remoteAddress}`,
253
260
  );
254
261
 
@@ -326,7 +333,7 @@ export class SftpMimic extends EventEmitter {
326
333
 
327
334
  // Add error handling for the session
328
335
  session.on("error", (error: unknown) => {
329
- console.error(
336
+ devErr(
330
337
  `[SFTP] Session error for user=${authUser}:`,
331
338
  error,
332
339
  );
@@ -349,7 +356,7 @@ export class SftpMimic extends EventEmitter {
349
356
  address && typeof address === "object" && "port" in address
350
357
  ? address.port
351
358
  : this.port;
352
- console.log(`SFTP Mimic listening on port ${actualPort}`);
359
+ devLog(`SFTP Mimic listening on port ${actualPort}`);
353
360
  this.emit("start", { port: actualPort });
354
361
  resolve(actualPort as number);
355
362
  });
@@ -360,7 +367,7 @@ export class SftpMimic extends EventEmitter {
360
367
  perf.mark("stop");
361
368
  if (this.server) {
362
369
  this.server.close(() => {
363
- console.log("SFTP Mimic stopped");
370
+ devLog("SFTP Mimic stopped");
364
371
  this.emit("stop");
365
372
  });
366
373
  }
@@ -452,7 +459,7 @@ export class SftpMimic extends EventEmitter {
452
459
 
453
460
  // Security: Confine to home directory
454
461
  if (!this.isPathWithinHome(targetPath, authUser)) {
455
- console.warn(
462
+ devWarn(
456
463
  `[SFTP] Path traversal attempt blocked: user=${authUser}, path=${targetPath}`,
457
464
  );
458
465
  sftp.status(reqid, SFTP_STATUS_CODE.PERMISSION_DENIED);
@@ -507,7 +514,7 @@ export class SftpMimic extends EventEmitter {
507
514
  });
508
515
  sftp.handle(reqid, handle);
509
516
  } catch (error) {
510
- console.error("SFTP OPEN error:", error);
517
+ devErr("SFTP OPEN error:", error);
511
518
  sftp.status(reqid, SFTP_STATUS_CODE.FAILURE);
512
519
  }
513
520
  });
@@ -580,7 +587,7 @@ export class SftpMimic extends EventEmitter {
580
587
  getVfs().writeFile(entry.path, entry.buffer);
581
588
  void getVfs().flushMirror();
582
589
  } catch (error) {
583
- console.error("SFTP CLOSE write error:", error);
590
+ devErr("SFTP CLOSE write error:", error);
584
591
  sftp.status(reqid, SFTP_STATUS_CODE.FAILURE);
585
592
  this.closeHandle(handle);
586
593
  return;
@@ -596,7 +603,7 @@ export class SftpMimic extends EventEmitter {
596
603
 
597
604
  // Security: Confine to home directory
598
605
  if (!this.isPathWithinHome(targetPath, authUser)) {
599
- console.warn(
606
+ devWarn(
600
607
  `[SFTP] Path traversal attempt blocked: user=${authUser}, path=${targetPath}`,
601
608
  );
602
609
  sftp.status(reqid, SFTP_STATUS_CODE.PERMISSION_DENIED);
@@ -649,7 +656,7 @@ export class SftpMimic extends EventEmitter {
649
656
 
650
657
  // Security: Confine to home directory
651
658
  if (!this.isPathWithinHome(targetPath, authUser)) {
652
- console.warn(
659
+ devWarn(
653
660
  `[SFTP] Path traversal attempt blocked: user=${authUser}, path=${targetPath}`,
654
661
  );
655
662
  sftp.status(reqid, SFTP_STATUS_CODE.PERMISSION_DENIED);
@@ -669,7 +676,7 @@ export class SftpMimic extends EventEmitter {
669
676
 
670
677
  // Security: Confine to home directory
671
678
  if (!this.isPathWithinHome(targetPath, authUser)) {
672
- console.warn(
679
+ devWarn(
673
680
  `[SFTP] Path traversal attempt blocked: user=${authUser}, path=${targetPath}`,
674
681
  );
675
682
  sftp.status(reqid, SFTP_STATUS_CODE.PERMISSION_DENIED);
@@ -711,7 +718,7 @@ export class SftpMimic extends EventEmitter {
711
718
 
712
719
  // Security: Confine to home directory
713
720
  if (!this.isPathWithinHome(targetPath, authUser)) {
714
- console.warn(
721
+ devWarn(
715
722
  `[SFTP] Path traversal attempt blocked: user=${authUser}, path=${targetPath}`,
716
723
  );
717
724
  sftp.status(reqid, SFTP_STATUS_CODE.PERMISSION_DENIED);
@@ -734,7 +741,7 @@ export class SftpMimic extends EventEmitter {
734
741
 
735
742
  // Security: Confine to home directory
736
743
  if (!this.isPathWithinHome(normalized, authUser)) {
737
- console.warn(
744
+ devWarn(
738
745
  `[SFTP] Path traversal attempt blocked: user=${authUser}, path=${normalized}`,
739
746
  );
740
747
  sftp.status(reqid, SFTP_STATUS_CODE.PERMISSION_DENIED);
@@ -762,7 +769,7 @@ export class SftpMimic extends EventEmitter {
762
769
 
763
770
  // Security: Confine to home directory
764
771
  if (!this.isPathWithinHome(targetPath, authUser)) {
765
- console.warn(
772
+ devWarn(
766
773
  `[SFTP] Path traversal attempt blocked: user=${authUser}, path=${targetPath}`,
767
774
  );
768
775
  sftp.status(reqid, SFTP_STATUS_CODE.PERMISSION_DENIED);
@@ -783,7 +790,7 @@ export class SftpMimic extends EventEmitter {
783
790
 
784
791
  // Security: Confine to home directory
785
792
  if (!this.isPathWithinHome(targetPath, authUser)) {
786
- console.warn(
793
+ devWarn(
787
794
  `[SFTP] Path traversal attempt blocked: user=${authUser}, path=${targetPath}`,
788
795
  );
789
796
  sftp.status(reqid, SFTP_STATUS_CODE.PERMISSION_DENIED);
@@ -804,7 +811,7 @@ export class SftpMimic extends EventEmitter {
804
811
 
805
812
  // Security: Confine to home directory
806
813
  if (!this.isPathWithinHome(targetPath, authUser)) {
807
- console.warn(
814
+ devWarn(
808
815
  `[SFTP] Path traversal attempt blocked: user=${authUser}, path=${targetPath}`,
809
816
  );
810
817
  sftp.status(reqid, SFTP_STATUS_CODE.PERMISSION_DENIED);
@@ -829,7 +836,7 @@ export class SftpMimic extends EventEmitter {
829
836
  !this.isPathWithinHome(fromPath, authUser) ||
830
837
  !this.isPathWithinHome(toPath, authUser)
831
838
  ) {
832
- console.warn(
839
+ devWarn(
833
840
  `[SFTP] Path traversal attempt blocked: user=${authUser}, from=${fromPath}, to=${toPath}`,
834
841
  );
835
842
  sftp.status(reqid, SFTP_STATUS_CODE.PERMISSION_DENIED);
@@ -854,21 +861,21 @@ export class SftpMimic extends EventEmitter {
854
861
  });
855
862
 
856
863
  sftp.on("error", (error: Error) => {
857
- console.error(`[SFTP] Stream error for user=${authUser}:`, error);
864
+ devErr(`[SFTP] Stream error for user=${authUser}:`, error);
858
865
  });
859
866
 
860
867
  sftp.on("close", () => {
861
- console.log(`[SFTP] Stream closed for user=${authUser}`);
868
+ devLog(`[SFTP] Stream closed for user=${authUser}`);
862
869
  this.handles.clear();
863
870
  });
864
871
 
865
872
  sftp.on("end", () => {
866
- console.log(`[SFTP] end event for user=${authUser}`);
873
+ devLog(`[SFTP] end event for user=${authUser}`);
867
874
  this.handles.clear();
868
875
  });
869
876
 
870
877
  sftp.on("END", () => {
871
- console.log(`[SFTP] END event for user=${authUser}`);
878
+ devLog(`[SFTP] END event for user=${authUser}`);
872
879
  this.handles.clear();
873
880
  });
874
881
  }
@@ -15,7 +15,7 @@ import {
15
15
  } from "../modules/shellRuntime";
16
16
  import { buildLoginBanner } from "../SSHMimic/loginBanner";
17
17
  import { buildPrompt } from "../SSHMimic/prompt";
18
- import type { ShellEnv } from "../types/commands";
18
+ import type { CommandResult, ShellEnv } from "../types/commands";
19
19
  import type { ShellStream } from "../types/streams";
20
20
  import type VirtualFileSystem from "../VirtualFileSystem";
21
21
 
@@ -33,6 +33,11 @@ interface PendingSudo {
33
33
  loginShell: boolean;
34
34
  prompt: string;
35
35
  buffer: string;
36
+ mode?: "sudo" | "passwd" | "confirm";
37
+ onPassword?: (input: string, shell: VirtualShell) => Promise<{
38
+ result: CommandResult | null;
39
+ nextPrompt?: string;
40
+ }>;
36
41
  }
37
42
 
38
43
  export function startShell(
@@ -110,6 +115,11 @@ export function startShell(
110
115
  commandLine: string | null;
111
116
  loginShell: boolean;
112
117
  prompt: string;
118
+ mode?: "sudo" | "passwd" | "confirm";
119
+ onPassword?: (input: string, shell: VirtualShell) => Promise<{
120
+ result: CommandResult | null;
121
+ nextPrompt?: string;
122
+ }>;
113
123
  }): void {
114
124
  pendingSudo = {
115
125
  ...challenge,
@@ -135,7 +145,9 @@ export function startShell(
135
145
 
136
146
  if (!challenge.commandLine) {
137
147
  authUser = challenge.targetUser;
138
- cwd = `/home/${authUser}`;
148
+ if (challenge.loginShell) {
149
+ cwd = `/home/${authUser}`;
150
+ }
139
151
  shell.users.updateSession(sessionId, authUser, remoteAddress);
140
152
  stream.write("\r\n");
141
153
  renderLine();
@@ -443,11 +455,29 @@ export function startShell(
443
455
  }
444
456
 
445
457
  if (ch === "\r" || ch === "\n") {
446
- const password = pendingSudo.buffer;
458
+ const typed = pendingSudo.buffer;
447
459
  pendingSudo.buffer = "";
460
+
461
+ // ── Generic onPassword handler (passwd / confirm modes) ────
462
+ if (pendingSudo.onPassword) {
463
+ const { result, nextPrompt } = await pendingSudo.onPassword(typed, shell);
464
+ stream.write("\r\n");
465
+ if (result !== null) {
466
+ pendingSudo = null;
467
+ if (result.stdout) stream.write(result.stdout.replace(/\n/g, "\r\n"));
468
+ if (result.stderr) stream.write(result.stderr.replace(/\n/g, "\r\n"));
469
+ renderLine();
470
+ } else {
471
+ if (nextPrompt) pendingSudo.prompt = nextPrompt;
472
+ stream.write(pendingSudo.prompt);
473
+ }
474
+ return;
475
+ }
476
+
477
+ // ── Default sudo mode — verify current user's password ─────
448
478
  const valid = shell.users.verifyPassword(
449
479
  pendingSudo.username,
450
- password,
480
+ typed,
451
481
  );
452
482
  await finishSudoPrompt(valid);
453
483
  return;
@@ -5,6 +5,7 @@ import type {
5
5
  Statement,
6
6
  LogicalOp,
7
7
  } from "../types/pipeline";
8
+ import { tokenizeCommand } from "../utils/tokenize";
8
9
 
9
10
  // ── Public API ───────────────────────────────────────────────────────────────
10
11
 
@@ -25,7 +26,7 @@ export function parseScript(rawInput: string): Script {
25
26
  }
26
27
  }
27
28
 
28
- /** Legacy compat: parse a single pipeline (no &&/||/;) */
29
+ /** Parse a single pipeline string (no &&/||/;) into a `Pipeline` object. */
29
30
  export function parseShellPipeline(rawInput: string): Pipeline {
30
31
  const trimmed = rawInput.trim();
31
32
  if (!trimmed) return { commands: [], isValid: true };
@@ -39,49 +40,6 @@ export function parseShellPipeline(rawInput: string): Pipeline {
39
40
 
40
41
  // ── Variable & tilde expansion ────────────────────────────────────────────────
41
42
 
42
- /**
43
- * Expand ~ and $VAR / ${VAR} / ${VAR:-default} / $(cmd placeholder) in a
44
- * token, given the current env vars and home path.
45
- * Command substitution $(…) is NOT executed here — it's left as a marker so
46
- * the executor can handle it.
47
- */
48
- export function expandToken(
49
- token: string,
50
- env: Record<string, string>,
51
- authUser: string,
52
- lastExitCode = 0,
53
- ): string {
54
- // tilde expansion
55
- token = token.replace(/^~(\/|$)/, `/home/${authUser}$1`);
56
-
57
- // $? special var
58
- token = token.replace(/\$\?/g, String(lastExitCode));
59
- // $$ PID (mock)
60
- token = token.replace(/\$\$/g, "1");
61
- // $# argc (0 for interactive)
62
- token = token.replace(/\$#/g, "0");
63
-
64
- // ${VAR:-default} and ${VAR:+value}
65
- token = token.replace(
66
- /\$\{([^}:]+):-([^}]*)\}/g,
67
- (_, name, def) => env[name] ?? def,
68
- );
69
- token = token.replace(/\$\{([^}:]+):\+([^}]*)\}/g, (_, name, val) =>
70
- env[name] ? val : "",
71
- );
72
-
73
- // ${VAR}
74
- token = token.replace(/\$\{([^}]+)\}/g, (_, name) => env[name] ?? "");
75
-
76
- // $VAR (greedy: match longest valid identifier)
77
- token = token.replace(
78
- /\$([A-Za-z_][A-Za-z0-9_]*)/g,
79
- (_, name) => env[name] ?? "",
80
- );
81
-
82
- return token;
83
- }
84
-
85
43
  /**
86
44
  * Expand glob patterns (*, ?, [abc]) against a list of entries.
87
45
  * Returns the original pattern if no match.
@@ -299,62 +257,3 @@ function parseCommandWithRedirections(token: string): PipelineCommand {
299
257
  return { name, args: cmdParts.slice(1), inputFile, outputFile, appendOutput };
300
258
  }
301
259
 
302
- function tokenizeCommand(input: string): string[] {
303
- const tokens: string[] = [];
304
- let current = "";
305
- let inQ = false;
306
- let qChar = "";
307
- let i = 0;
308
-
309
- while (i < input.length) {
310
- const ch = input[i]!;
311
- const next = input[i + 1];
312
-
313
- if ((ch === '"' || ch === "'") && !inQ) {
314
- inQ = true;
315
- qChar = ch;
316
- i++;
317
- continue;
318
- }
319
- if (inQ && ch === qChar) {
320
- inQ = false;
321
- qChar = "";
322
- i++;
323
- continue;
324
- }
325
- if (inQ) {
326
- current += ch;
327
- i++;
328
- continue;
329
- }
330
-
331
- if (ch === " ") {
332
- if (current) {
333
- tokens.push(current);
334
- current = "";
335
- }
336
- i++;
337
- continue;
338
- }
339
-
340
- if ((ch === ">" || ch === "<") && !inQ) {
341
- if (current) {
342
- tokens.push(current);
343
- current = "";
344
- }
345
- if (ch === ">" && next === ">") {
346
- tokens.push(">>");
347
- i += 2;
348
- } else {
349
- tokens.push(ch);
350
- i++;
351
- }
352
- continue;
353
- }
354
-
355
- current += ch;
356
- i++;
357
- }
358
- if (current) tokens.push(current);
359
- return tokens;
360
- }
@@ -1,4 +1,4 @@
1
- import { createHash, randomBytes, randomUUID, scryptSync } from "node:crypto";
1
+ import { createHash, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
2
2
  import { EventEmitter } from "node:events";
3
3
  import * as path from "node:path";
4
4
  import type { PerfLogger } from "../utils/perfLogger";
@@ -99,15 +99,15 @@ export class VirtualUserManager extends EventEmitter {
99
99
  // this.users.set(currentUser, this.createRecord(currentUser, userPassword));
100
100
  // this.sudoers.add(currentUser);
101
101
  // changed = true;
102
-
103
- // const homePath = `/home/${currentUser}`;
104
- // if (!this.vfs.exists(homePath)) {
105
- // this.vfs.mkdir(homePath, 0o755);
106
- // this.vfs.writeFile(
107
- // `${homePath}/README.txt`,
108
- // `Welcome to the virtual environment, ${currentUser}`,
109
- // );
110
- // }
102
+ // }
103
+
104
+ // const homePath = `/home/root`;
105
+ // if (!this.vfs.exists(homePath)) {
106
+ // this.vfs.mkdir(homePath, 0o755);
107
+ // this.vfs.writeFile(
108
+ // `${homePath}/README.txt`,
109
+ // `Welcome to the virtual environment, root`,
110
+ // );
111
111
  // }
112
112
 
113
113
  if (changed) {
@@ -239,10 +239,22 @@ export class VirtualUserManager extends EventEmitter {
239
239
  perf.mark("verifyPassword");
240
240
  const record = this.users.get(username);
241
241
  if (!record) {
242
+ // Perform a dummy hash to avoid timing leakage on unknown usernames
243
+ this.hashPassword(password, "");
242
244
  return false;
243
245
  }
244
246
 
245
- return this.hashPassword(password) === record.passwordHash;
247
+ const computed = this.hashPassword(password, record.salt);
248
+ const expected = record.passwordHash;
249
+ // timingSafeEqual prevents timing-based password oracle attacks
250
+ try {
251
+ const a = Buffer.from(computed, "hex");
252
+ const b = Buffer.from(expected, "hex");
253
+ if (a.length !== b.length) return false;
254
+ return timingSafeEqual(a, b);
255
+ } catch {
256
+ return computed === expected;
257
+ }
246
258
  }
247
259
 
248
260
  /**
@@ -617,7 +629,8 @@ export class VirtualUserManager extends EventEmitter {
617
629
  }
618
630
 
619
631
  private createRecord(username: string, password: string): VirtualUserRecord {
620
- const cacheKey = `${username}:${password}`;
632
+ // Cache key is a hash of the inputs — never store plaintext password in memory
633
+ const cacheKey = createHash("sha256").update(username).update(":").update(password).digest("hex");
621
634
  const cached = VirtualUserManager.recordCache.get(cacheKey);
622
635
  if (cached) {
623
636
  return cached;
@@ -627,7 +640,8 @@ export class VirtualUserManager extends EventEmitter {
627
640
  const record = {
628
641
  username,
629
642
  salt,
630
- passwordHash: this.hashPassword(password),
643
+ // Hash uses the generated salt — verifyPassword must use record.salt
644
+ passwordHash: this.hashPassword(password, salt),
631
645
  };
632
646
 
633
647
  VirtualUserManager.recordCache.set(cacheKey, record);
@@ -644,11 +658,12 @@ export class VirtualUserManager extends EventEmitter {
644
658
  */
645
659
  public hasPassword(username: string): boolean {
646
660
  perf.mark("hasPassword");
647
- if (this.getPasswordHash(username) === this.hashPassword("")) {
648
- return false;
649
- }
650
661
  const record = this.users.get(username);
651
- return !!record && !!record.passwordHash;
662
+ if (!record) return false;
663
+ // Empty password hash computed with the record's own salt
664
+ const emptyHash = this.hashPassword("", record.salt);
665
+ if (record.passwordHash === emptyHash) return false;
666
+ return !!record.passwordHash;
652
667
  }
653
668
 
654
669
  /**
@@ -660,12 +675,21 @@ export class VirtualUserManager extends EventEmitter {
660
675
  * @param password Plaintext password string.
661
676
  * @returns Hex-encoded hash string.
662
677
  */
663
- public hashPassword(password: string): string {
678
+ /**
679
+ * Hash a password with an optional salt.
680
+ * When salt is provided (verify path), the same salt is used for a
681
+ * deterministic hash. When omitted (create path), an empty salt is used
682
+ * for backward compat — callers should pass the stored salt on verify.
683
+ */
684
+ public hashPassword(password: string, salt = ""): string {
664
685
  if (VirtualUserManager.fastPasswordHash) {
665
- return createHash("sha256").update(`${password}`).digest("hex");
686
+ return createHash("sha256")
687
+ .update(salt)
688
+ .update(password)
689
+ .digest("hex");
666
690
  }
667
691
 
668
- return scryptSync(password, "", 32).toString("hex");
692
+ return scryptSync(password, salt || "", 32).toString("hex");
669
693
  }
670
694
 
671
695
  private validateUsername(username: string): void {