typescript-virtual-container 1.0.1 → 1.0.3
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/.github/workflows/test-battery.yml +31 -6
- package/CHANGELOG.md +9 -0
- package/README.md +5 -1
- package/package.json +1 -1
- package/src/SSHMimic/commands/cat.ts +3 -2
- package/src/SSHMimic/commands/cd.ts +3 -2
- package/src/SSHMimic/commands/curl.ts +9 -3
- package/src/SSHMimic/commands/helpers.ts +26 -0
- package/src/SSHMimic/commands/ls.ts +3 -2
- package/src/SSHMimic/commands/mkdir.ts +5 -3
- package/src/SSHMimic/commands/nano.ts +3 -2
- package/src/SSHMimic/commands/rm.ts +5 -3
- package/src/SSHMimic/commands/touch.ts +3 -2
- package/src/SSHMimic/commands/tree.ts +3 -2
- package/src/SSHMimic/commands/wget.ts +3 -1
- package/src/SSHMimic/index.ts +25 -1
- package/src/SSHMimic/users.ts +16 -7
- package/tests/helpers.test.ts +22 -0
- package/tests/users.test.ts +41 -0
- package/tsconfig.json +1 -1
|
@@ -8,9 +8,8 @@ permissions:
|
|
|
8
8
|
contents: read
|
|
9
9
|
|
|
10
10
|
jobs:
|
|
11
|
-
|
|
11
|
+
setup:
|
|
12
12
|
runs-on: ubuntu-latest
|
|
13
|
-
|
|
14
13
|
steps:
|
|
15
14
|
- name: Checkout repository
|
|
16
15
|
uses: actions/checkout@v4
|
|
@@ -23,12 +22,36 @@ jobs:
|
|
|
23
22
|
- name: Install dependencies
|
|
24
23
|
run: bun install
|
|
25
24
|
|
|
25
|
+
- name: Cache dependencies
|
|
26
|
+
uses: actions/cache@v3
|
|
27
|
+
with:
|
|
28
|
+
path: node_modules
|
|
29
|
+
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lockb') }}
|
|
30
|
+
|
|
31
|
+
typecheck:
|
|
32
|
+
needs: setup
|
|
33
|
+
runs-on: ubuntu-latest
|
|
34
|
+
steps:
|
|
35
|
+
- name: Checkout repository
|
|
36
|
+
uses: actions/checkout@v4
|
|
37
|
+
|
|
38
|
+
- name: Setup Bun
|
|
39
|
+
uses: oven-sh/setup-bun@v1
|
|
40
|
+
with:
|
|
41
|
+
bun-version: latest
|
|
42
|
+
|
|
43
|
+
- name: Restore dependencies
|
|
44
|
+
uses: actions/cache@v3
|
|
45
|
+
with:
|
|
46
|
+
path: node_modules
|
|
47
|
+
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lockb') }}
|
|
48
|
+
|
|
26
49
|
- name: Run typecheck
|
|
27
50
|
run: bun check
|
|
28
51
|
|
|
29
52
|
lint:
|
|
53
|
+
needs: setup
|
|
30
54
|
runs-on: ubuntu-latest
|
|
31
|
-
|
|
32
55
|
steps:
|
|
33
56
|
- name: Checkout repository
|
|
34
57
|
uses: actions/checkout@v4
|
|
@@ -38,8 +61,11 @@ jobs:
|
|
|
38
61
|
with:
|
|
39
62
|
bun-version: latest
|
|
40
63
|
|
|
41
|
-
- name:
|
|
42
|
-
|
|
64
|
+
- name: Restore dependencies
|
|
65
|
+
uses: actions/cache@v3
|
|
66
|
+
with:
|
|
67
|
+
path: node_modules
|
|
68
|
+
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lockb') }}
|
|
43
69
|
|
|
44
70
|
- name: Run lint
|
|
45
71
|
run: bun lint
|
|
@@ -49,7 +75,6 @@ jobs:
|
|
|
49
75
|
- typecheck
|
|
50
76
|
- lint
|
|
51
77
|
runs-on: ubuntu-latest
|
|
52
|
-
|
|
53
78
|
steps:
|
|
54
79
|
- name: Test battery passed
|
|
55
80
|
run: echo "Typecheck, lint and tests succeeded."
|
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,8 @@ The format is based on Keep a Changelog.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [1.0.2] - 2026-04-14
|
|
10
|
+
|
|
9
11
|
### Added
|
|
10
12
|
|
|
11
13
|
- Governance and community files:
|
|
@@ -14,6 +16,13 @@ The format is based on Keep a Changelog.
|
|
|
14
16
|
- SECURITY.md
|
|
15
17
|
- CODE_OF_CONDUCT.md
|
|
16
18
|
- GitHub issue and PR templates
|
|
19
|
+
- Security hardening for virtual auth storage and shell access:
|
|
20
|
+
- Non-root commands now block access to `/virtual-env-js/.auth/**`.
|
|
21
|
+
- Auth files are persisted with restrictive modes (`0700` for `.auth`, `0600` for `htpasswd` and `sudoers`).
|
|
22
|
+
- Root password no longer falls back to a fixed default when `SSH_MIMIC_ROOT_PASSWORD` is unset; startup generates an ephemeral password instead.
|
|
23
|
+
- New environment toggle `SSH_MIMIC_AUTO_SUDO_NEW_USERS` to control whether newly created users are added to sudoers by default.
|
|
24
|
+
- README and security docs now describe the new auth hardening and configuration flags.
|
|
25
|
+
- Added tests covering `.auth` path protection and auto-sudo behavior.
|
|
17
26
|
|
|
18
27
|
## [1.0.1] - 2026-04-14
|
|
19
28
|
|
package/README.md
CHANGED
|
@@ -1040,13 +1040,15 @@ The following commands are available in both SSH shell mode and via `SshClient.e
|
|
|
1040
1040
|
### Environment Variables
|
|
1041
1041
|
|
|
1042
1042
|
- **`SSH_MIMIC_HOSTNAME`**: Override server hostname at startup (default: "typescript-vm")
|
|
1043
|
-
- **`SSH_MIMIC_ROOT_PASSWORD`**: Set root password
|
|
1043
|
+
- **`SSH_MIMIC_ROOT_PASSWORD`**: Set root password. If unset, a random ephemeral password is generated at startup and logged once.
|
|
1044
|
+
- **`SSH_MIMIC_AUTO_SUDO_NEW_USERS`**: Control whether new users are added to sudoers automatically (default: enabled). Set to `0`, `false`, `no`, or `off` to disable.
|
|
1044
1045
|
|
|
1045
1046
|
**Example:**
|
|
1046
1047
|
|
|
1047
1048
|
```bash
|
|
1048
1049
|
export SSH_MIMIC_HOSTNAME=production-lab
|
|
1049
1050
|
export SSH_MIMIC_ROOT_PASSWORD=SecurePass123
|
|
1051
|
+
export SSH_MIMIC_AUTO_SUDO_NEW_USERS=false
|
|
1050
1052
|
npm run start
|
|
1051
1053
|
```
|
|
1052
1054
|
|
|
@@ -1231,6 +1233,8 @@ const ssh = new VirtualMachine({ port: 2222, hostname: "hostname" });
|
|
|
1231
1233
|
- Passwords are hashed with `scrypt` in the virtual auth store.
|
|
1232
1234
|
- Root account is always protected and cannot be deleted.
|
|
1233
1235
|
- Sudo privileges are explicit and persisted in sudoers data.
|
|
1236
|
+
- Protect the root password in production by setting `SSH_MIMIC_ROOT_PASSWORD`; otherwise startup logs a generated ephemeral password.
|
|
1237
|
+
- Disable `SSH_MIMIC_AUTO_SUDO_NEW_USERS` when you want newly created users to stay unprivileged by default.
|
|
1234
1238
|
- This project is not intended to provide kernel-level or process-level isolation.
|
|
1235
1239
|
|
|
1236
1240
|
If you discover a vulnerability, avoid public disclosure in issues and contact maintainers privately first.
|
package/package.json
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import type { ShellModule } from "../../types/commands";
|
|
2
|
-
import { resolveReadablePath } from "./helpers";
|
|
2
|
+
import { assertPathAccess, resolveReadablePath } from "./helpers";
|
|
3
3
|
|
|
4
4
|
export const catCommand: ShellModule = {
|
|
5
5
|
name: "cat",
|
|
6
6
|
params: ["<file>"],
|
|
7
|
-
run: ({ vfs, cwd, args }) => {
|
|
7
|
+
run: ({ authUser, vfs, cwd, args }) => {
|
|
8
8
|
const fileArg = args[0];
|
|
9
9
|
if (!fileArg) {
|
|
10
10
|
return { stderr: "cat: missing file operand", exitCode: 1 };
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
const target = resolveReadablePath(vfs, cwd, fileArg);
|
|
14
|
+
assertPathAccess(authUser, target, "cat");
|
|
14
15
|
return { stdout: vfs.readFile(target), exitCode: 0 };
|
|
15
16
|
},
|
|
16
17
|
};
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { ShellModule } from "../../types/commands";
|
|
2
|
-
import { resolvePath } from "./helpers";
|
|
2
|
+
import { assertPathAccess, resolvePath } from "./helpers";
|
|
3
3
|
|
|
4
4
|
export const cdCommand: ShellModule = {
|
|
5
5
|
name: "cd",
|
|
6
6
|
params: ["[path]"],
|
|
7
|
-
run: ({ vfs, cwd, args, mode }) => {
|
|
7
|
+
run: ({ authUser, vfs, cwd, args, mode }) => {
|
|
8
8
|
const target = resolvePath(cwd, args[0] ?? "/virtual-env-js");
|
|
9
|
+
assertPathAccess(authUser, target, "cd");
|
|
9
10
|
const stats = vfs.stat(target);
|
|
10
11
|
if (stats.type !== "directory") {
|
|
11
12
|
return { stderr: `cd: not a directory: ${target}`, exitCode: 1 };
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import type { ShellModule } from "../../types/commands";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
assertPathAccess,
|
|
5
|
+
normalizeTerminalOutput,
|
|
6
|
+
resolvePath,
|
|
7
|
+
} from "./helpers";
|
|
4
8
|
|
|
5
9
|
function parseCurlOutputPath(args: string[]): {
|
|
6
10
|
outputPath: string | null;
|
|
@@ -105,7 +109,7 @@ function runHostCurl(args: string[]): Promise<{
|
|
|
105
109
|
export const curlCommand: ShellModule = {
|
|
106
110
|
name: "curl",
|
|
107
111
|
params: ["[-o file] <url>"],
|
|
108
|
-
run: async ({ vfs, cwd, args }) => {
|
|
112
|
+
run: async ({ authUser, vfs, cwd, args }) => {
|
|
109
113
|
const { outputPath, inputArgs } = parseCurlOutputPath(args);
|
|
110
114
|
const url = inputArgs[0];
|
|
111
115
|
const isHelpLike = inputArgs.some(
|
|
@@ -130,7 +134,9 @@ export const curlCommand: ShellModule = {
|
|
|
130
134
|
}
|
|
131
135
|
|
|
132
136
|
if (outputPath) {
|
|
133
|
-
|
|
137
|
+
const target = resolvePath(cwd, outputPath);
|
|
138
|
+
assertPathAccess(authUser, target, "curl");
|
|
139
|
+
vfs.writeFile(target, result.stdout);
|
|
134
140
|
return {
|
|
135
141
|
stderr: result.stderr
|
|
136
142
|
? normalizeTerminalOutput(result.stderr)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import type VirtualFileSystem from "../../VirtualFileSystem";
|
|
3
3
|
|
|
4
|
+
const PROTECTED_PREFIXES = ["/virtual-env-js/.auth"] as const;
|
|
5
|
+
|
|
4
6
|
function normalizeFetchUrl(input: string): string {
|
|
5
7
|
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(input)) {
|
|
6
8
|
return input;
|
|
@@ -32,6 +34,30 @@ export function resolvePath(cwd: string, inputPath: string): string {
|
|
|
32
34
|
: path.posix.normalize(path.posix.join(cwd, inputPath));
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
function isProtectedPath(targetPath: string): boolean {
|
|
38
|
+
const normalized = targetPath.startsWith("/")
|
|
39
|
+
? path.posix.normalize(targetPath)
|
|
40
|
+
: path.posix.normalize(`/${targetPath}`);
|
|
41
|
+
|
|
42
|
+
return PROTECTED_PREFIXES.some(
|
|
43
|
+
(prefix) => normalized === prefix || normalized.startsWith(`${prefix}/`),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function assertPathAccess(
|
|
48
|
+
authUser: string,
|
|
49
|
+
targetPath: string,
|
|
50
|
+
operation: string,
|
|
51
|
+
): void {
|
|
52
|
+
if (authUser === "root") {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (isProtectedPath(targetPath)) {
|
|
57
|
+
throw new Error(`${operation}: permission denied: ${targetPath}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
35
61
|
export function parseOutputPath(args: string[]): {
|
|
36
62
|
outputPath: string | null;
|
|
37
63
|
inputArgs: string[];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ShellModule } from "../../types/commands";
|
|
2
|
-
import { joinListWithType, resolvePath } from "./helpers";
|
|
2
|
+
import { assertPathAccess, joinListWithType, resolvePath } from "./helpers";
|
|
3
3
|
|
|
4
4
|
function formatPermissions(mode: number, isDirectory: boolean): string {
|
|
5
5
|
const fileType = isDirectory ? "d" : "-";
|
|
@@ -28,10 +28,11 @@ function formatDate(date: Date): string {
|
|
|
28
28
|
export const lsCommand: ShellModule = {
|
|
29
29
|
name: "ls",
|
|
30
30
|
params: ["[path]"],
|
|
31
|
-
run: ({ vfs, cwd, args }) => {
|
|
31
|
+
run: ({ authUser, vfs, cwd, args }) => {
|
|
32
32
|
const longFormat = args.includes("-l") || args.includes("--long");
|
|
33
33
|
const targetArg = args.find((arg) => !arg.startsWith("-"));
|
|
34
34
|
const target = resolvePath(cwd, targetArg ?? cwd);
|
|
35
|
+
assertPathAccess(authUser, target, "ls");
|
|
35
36
|
const items = vfs.list(target).filter((name) => !name.startsWith("."));
|
|
36
37
|
const rendered = longFormat
|
|
37
38
|
? items
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import type { ShellModule } from "../../types/commands";
|
|
2
|
-
import { resolvePath } from "./helpers";
|
|
2
|
+
import { assertPathAccess, resolvePath } from "./helpers";
|
|
3
3
|
|
|
4
4
|
export const mkdirCommand: ShellModule = {
|
|
5
5
|
name: "mkdir",
|
|
6
6
|
params: ["<dir>"],
|
|
7
|
-
run: ({ vfs, cwd, args }) => {
|
|
7
|
+
run: ({ authUser, vfs, cwd, args }) => {
|
|
8
8
|
if (args.length === 0) {
|
|
9
9
|
return { stderr: "mkdir: missing operand", exitCode: 1 };
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
for (const dir of args) {
|
|
13
|
-
|
|
13
|
+
const target = resolvePath(cwd, dir);
|
|
14
|
+
assertPathAccess(authUser, target, "mkdir");
|
|
15
|
+
vfs.mkdir(target);
|
|
14
16
|
}
|
|
15
17
|
return { exitCode: 0 };
|
|
16
18
|
},
|
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import type { ShellModule } from "../../types/commands";
|
|
3
|
-
import { resolvePath } from "./helpers";
|
|
3
|
+
import { assertPathAccess, resolvePath } from "./helpers";
|
|
4
4
|
|
|
5
5
|
export const nanoCommand: ShellModule = {
|
|
6
6
|
name: "nano",
|
|
7
7
|
params: ["<file>"],
|
|
8
|
-
run: ({ vfs, cwd, args }) => {
|
|
8
|
+
run: ({ authUser, vfs, cwd, args }) => {
|
|
9
9
|
const fileArg = args[0];
|
|
10
10
|
if (!fileArg) {
|
|
11
11
|
return { stderr: "nano: missing file operand", exitCode: 1 };
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
const targetPath = resolvePath(cwd, fileArg);
|
|
15
|
+
assertPathAccess(authUser, targetPath, "nano");
|
|
15
16
|
const initialContent = vfs.exists(targetPath)
|
|
16
17
|
? vfs.readFile(targetPath)
|
|
17
18
|
: "";
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { ShellModule } from "../../types/commands";
|
|
2
|
-
import { resolvePath } from "./helpers";
|
|
2
|
+
import { assertPathAccess, resolvePath } from "./helpers";
|
|
3
3
|
|
|
4
4
|
export const rmCommand: ShellModule = {
|
|
5
5
|
name: "rm",
|
|
6
6
|
params: ["[-r|-rf] <path>"],
|
|
7
|
-
run: ({ vfs, cwd, args }) => {
|
|
7
|
+
run: ({ authUser, vfs, cwd, args }) => {
|
|
8
8
|
if (args.length === 0) {
|
|
9
9
|
return { stderr: "rm: missing operand", exitCode: 1 };
|
|
10
10
|
}
|
|
@@ -18,7 +18,9 @@ export const rmCommand: ShellModule = {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
for (const target of targets) {
|
|
21
|
-
|
|
21
|
+
const resolvedTarget = resolvePath(cwd, target);
|
|
22
|
+
assertPathAccess(authUser, resolvedTarget, "rm");
|
|
23
|
+
vfs.remove(resolvedTarget, { recursive });
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
return { exitCode: 0 };
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import type { ShellModule } from "../../types/commands";
|
|
2
|
-
import { resolvePath } from "./helpers";
|
|
2
|
+
import { assertPathAccess, resolvePath } from "./helpers";
|
|
3
3
|
|
|
4
4
|
export const touchCommand: ShellModule = {
|
|
5
5
|
name: "touch",
|
|
6
6
|
params: ["<file>"],
|
|
7
|
-
run: ({ vfs, cwd, args }) => {
|
|
7
|
+
run: ({ authUser, vfs, cwd, args }) => {
|
|
8
8
|
if (args.length === 0) {
|
|
9
9
|
return { stderr: "touch: missing file operand", exitCode: 1 };
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
for (const file of args) {
|
|
13
13
|
const target = resolvePath(cwd, file);
|
|
14
|
+
assertPathAccess(authUser, target, "touch");
|
|
14
15
|
if (!vfs.exists(target)) {
|
|
15
16
|
vfs.writeFile(target, "");
|
|
16
17
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { ShellModule } from "../../types/commands";
|
|
2
|
-
import { resolvePath } from "./helpers";
|
|
2
|
+
import { assertPathAccess, resolvePath } from "./helpers";
|
|
3
3
|
|
|
4
4
|
export const treeCommand: ShellModule = {
|
|
5
5
|
name: "tree",
|
|
6
6
|
params: ["[path]"],
|
|
7
|
-
run: ({ vfs, cwd, args }) => {
|
|
7
|
+
run: ({ authUser, vfs, cwd, args }) => {
|
|
8
8
|
const target = resolvePath(cwd, args[0] ?? cwd);
|
|
9
|
+
assertPathAccess(authUser, target, "tree");
|
|
9
10
|
return { stdout: vfs.tree(target), exitCode: 0 };
|
|
10
11
|
},
|
|
11
12
|
};
|
|
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import type { ShellModule } from "../../types/commands";
|
|
6
6
|
import {
|
|
7
|
+
assertPathAccess,
|
|
7
8
|
normalizeTerminalOutput,
|
|
8
9
|
parseOutputPath,
|
|
9
10
|
resolvePath,
|
|
@@ -81,7 +82,7 @@ function runHostWget(args: string[]): Promise<{
|
|
|
81
82
|
export const wgetCommand: ShellModule = {
|
|
82
83
|
name: "wget",
|
|
83
84
|
params: ["[url]"],
|
|
84
|
-
run: async ({ vfs, cwd, args }) => {
|
|
85
|
+
run: async ({ authUser, vfs, cwd, args }) => {
|
|
85
86
|
const { outputPath, inputArgs } = parseOutputPath(args);
|
|
86
87
|
const url = inputArgs[0];
|
|
87
88
|
const isHelpLike = inputArgs.some(
|
|
@@ -122,6 +123,7 @@ export const wgetCommand: ShellModule = {
|
|
|
122
123
|
|
|
123
124
|
const content = await readFile(tempFile, "utf8");
|
|
124
125
|
const target = resolvePath(cwd, outputPath ?? stripUrlFilename(url));
|
|
126
|
+
assertPathAccess(authUser, target, "wget");
|
|
125
127
|
vfs.writeFile(target, content);
|
|
126
128
|
|
|
127
129
|
return {
|
package/src/SSHMimic/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
1
2
|
import { Server as SshServer } from "ssh2";
|
|
2
3
|
import VirtualFileSystem from "../VirtualFileSystem";
|
|
3
4
|
import { runExec } from "./exec";
|
|
@@ -5,6 +6,28 @@ import { loadOrCreateHostKey } from "./hostKey";
|
|
|
5
6
|
import { startShell } from "./shell";
|
|
6
7
|
import { VirtualUserManager } from "./users";
|
|
7
8
|
|
|
9
|
+
function resolveRootPassword(): string {
|
|
10
|
+
const configured = process.env.SSH_MIMIC_ROOT_PASSWORD;
|
|
11
|
+
if (configured && configured.trim().length > 0) {
|
|
12
|
+
return configured;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const generated = randomBytes(18).toString("base64url");
|
|
16
|
+
console.warn(
|
|
17
|
+
`[ssh-mimic] SSH_MIMIC_ROOT_PASSWORD missing; generated ephemeral root password: ${generated}`,
|
|
18
|
+
);
|
|
19
|
+
return generated;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveAutoSudoForNewUsers(): boolean {
|
|
23
|
+
const configured = process.env.SSH_MIMIC_AUTO_SUDO_NEW_USERS;
|
|
24
|
+
if (!configured) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return !["0", "false", "no", "off"].includes(configured.toLowerCase());
|
|
29
|
+
}
|
|
30
|
+
|
|
8
31
|
/**
|
|
9
32
|
* SSH server wrapper that exposes virtual shell and exec sessions.
|
|
10
33
|
*
|
|
@@ -52,7 +75,8 @@ class SshMimic {
|
|
|
52
75
|
await this.vfs.restoreMirror();
|
|
53
76
|
this.users = new VirtualUserManager(
|
|
54
77
|
this.vfs,
|
|
55
|
-
|
|
78
|
+
resolveRootPassword(),
|
|
79
|
+
resolveAutoSudoForNewUsers(),
|
|
56
80
|
);
|
|
57
81
|
await this.users.initialize();
|
|
58
82
|
|
package/src/SSHMimic/users.ts
CHANGED
|
@@ -31,6 +31,7 @@ export interface VirtualActiveSession {
|
|
|
31
31
|
export class VirtualUserManager {
|
|
32
32
|
private readonly usersPath = "/virtual-env-js/.auth/htpasswd";
|
|
33
33
|
private readonly sudoersPath = "/virtual-env-js/.auth/sudoers";
|
|
34
|
+
private readonly authDirPath = "/virtual-env-js/.auth";
|
|
34
35
|
private readonly users = new Map<string, VirtualUserRecord>();
|
|
35
36
|
private readonly sudoers = new Set<string>();
|
|
36
37
|
private readonly activeSessions = new Map<string, VirtualActiveSession>();
|
|
@@ -45,6 +46,7 @@ export class VirtualUserManager {
|
|
|
45
46
|
constructor(
|
|
46
47
|
private readonly vfs: VirtualFileSystem,
|
|
47
48
|
private readonly defaultRootPassword: string = "root",
|
|
49
|
+
private readonly autoSudoForNewUsers: boolean = true,
|
|
48
50
|
) {}
|
|
49
51
|
|
|
50
52
|
/**
|
|
@@ -54,12 +56,7 @@ export class VirtualUserManager {
|
|
|
54
56
|
this.loadFromVfs();
|
|
55
57
|
this.loadSudoersFromVfs();
|
|
56
58
|
|
|
57
|
-
|
|
58
|
-
this.users.set(
|
|
59
|
-
"root",
|
|
60
|
-
this.createRecord("root", this.defaultRootPassword),
|
|
61
|
-
);
|
|
62
|
-
}
|
|
59
|
+
this.users.set("root", this.createRecord("root", this.defaultRootPassword));
|
|
63
60
|
|
|
64
61
|
this.sudoers.add("root");
|
|
65
62
|
|
|
@@ -97,7 +94,9 @@ export class VirtualUserManager {
|
|
|
97
94
|
}
|
|
98
95
|
|
|
99
96
|
this.users.set(username, this.createRecord(username, password));
|
|
100
|
-
this.
|
|
97
|
+
if (this.autoSudoForNewUsers) {
|
|
98
|
+
this.sudoers.add(username);
|
|
99
|
+
}
|
|
101
100
|
const homePath = `/home/${username}`;
|
|
102
101
|
if (!this.vfs.exists(homePath)) {
|
|
103
102
|
this.vfs.mkdir(homePath, 0o755);
|
|
@@ -290,6 +289,10 @@ export class VirtualUserManager {
|
|
|
290
289
|
}
|
|
291
290
|
|
|
292
291
|
private async persist(): Promise<void> {
|
|
292
|
+
if (!this.vfs.exists(this.authDirPath)) {
|
|
293
|
+
this.vfs.mkdir(this.authDirPath, 0o700);
|
|
294
|
+
}
|
|
295
|
+
|
|
293
296
|
const content = Array.from(this.users.values())
|
|
294
297
|
.sort((left, right) => left.username.localeCompare(right.username))
|
|
295
298
|
.map((record) =>
|
|
@@ -300,11 +303,13 @@ export class VirtualUserManager {
|
|
|
300
303
|
this.vfs.writeFile(
|
|
301
304
|
this.usersPath,
|
|
302
305
|
content.length > 0 ? `${content}\n` : "",
|
|
306
|
+
{ mode: 0o600 },
|
|
303
307
|
);
|
|
304
308
|
const sudoersContent = Array.from(this.sudoers.values()).sort().join("\n");
|
|
305
309
|
this.vfs.writeFile(
|
|
306
310
|
this.sudoersPath,
|
|
307
311
|
sudoersContent.length > 0 ? `${sudoersContent}\n` : "",
|
|
312
|
+
{ mode: 0o600 },
|
|
308
313
|
);
|
|
309
314
|
await this.vfs.flushMirror();
|
|
310
315
|
}
|
|
@@ -326,6 +331,10 @@ export class VirtualUserManager {
|
|
|
326
331
|
if (!username || username.trim() === "") {
|
|
327
332
|
throw new Error("invalid username");
|
|
328
333
|
}
|
|
334
|
+
|
|
335
|
+
if (!/^[a-z_][a-z0-9_-]{0,31}$/i.test(username)) {
|
|
336
|
+
throw new Error("invalid username");
|
|
337
|
+
}
|
|
329
338
|
}
|
|
330
339
|
|
|
331
340
|
private validatePassword(password: string): void {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { assertPathAccess } from "../src/SSHMimic/commands/helpers";
|
|
3
|
+
|
|
4
|
+
describe("assertPathAccess", () => {
|
|
5
|
+
test("blocks non-root access to auth store", () => {
|
|
6
|
+
expect(() =>
|
|
7
|
+
assertPathAccess("alice", "/virtual-env-js/.auth/htpasswd", "cat"),
|
|
8
|
+
).toThrow("cat: permission denied: /virtual-env-js/.auth/htpasswd");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("allows root access to auth store", () => {
|
|
12
|
+
expect(() =>
|
|
13
|
+
assertPathAccess("root", "/virtual-env-js/.auth/htpasswd", "cat"),
|
|
14
|
+
).not.toThrow();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("allows non-root access outside protected paths", () => {
|
|
18
|
+
expect(() =>
|
|
19
|
+
assertPathAccess("alice", "/home/alice/README.txt", "cat"),
|
|
20
|
+
).not.toThrow();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { VirtualUserManager } from "../src/SSHMimic/users";
|
|
6
|
+
import VirtualFileSystem from "../src/VirtualFileSystem";
|
|
7
|
+
|
|
8
|
+
async function withTempVfs(
|
|
9
|
+
run: (vfs: VirtualFileSystem) => Promise<void>,
|
|
10
|
+
): Promise<void> {
|
|
11
|
+
const tempDir = await mkdtemp(join(tmpdir(), "virtual-env-js-test-"));
|
|
12
|
+
try {
|
|
13
|
+
const vfs = new VirtualFileSystem(tempDir);
|
|
14
|
+
await vfs.restoreMirror();
|
|
15
|
+
await run(vfs);
|
|
16
|
+
} finally {
|
|
17
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("VirtualUserManager auto sudo", () => {
|
|
22
|
+
test("adds new users to sudoers by default", async () => {
|
|
23
|
+
await withTempVfs(async (vfs) => {
|
|
24
|
+
const users = new VirtualUserManager(vfs, "root-pass");
|
|
25
|
+
await users.initialize();
|
|
26
|
+
await users.addUser("alice", "alice-pass");
|
|
27
|
+
|
|
28
|
+
expect(users.isSudoer("alice")).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("does not auto-add sudoers when disabled", async () => {
|
|
33
|
+
await withTempVfs(async (vfs) => {
|
|
34
|
+
const users = new VirtualUserManager(vfs, "root-pass", false);
|
|
35
|
+
await users.initialize();
|
|
36
|
+
await users.addUser("bob", "bob-pass");
|
|
37
|
+
|
|
38
|
+
expect(users.isSudoer("bob")).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
});
|