start-command 0.9.0 → 0.10.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.
- package/CHANGELOG.md +36 -0
- package/README.md +49 -9
- package/REQUIREMENTS.md +48 -2
- package/experiments/user-isolation-research.md +83 -0
- package/package.json +1 -1
- package/src/bin/cli.js +131 -44
- package/src/lib/args-parser.js +62 -0
- package/src/lib/isolation.js +96 -40
- package/src/lib/user-manager.js +429 -0
- package/test/args-parser.test.js +179 -0
- package/test/isolation.test.js +33 -0
- package/test/user-manager.test.js +286 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User Manager for start-command
|
|
3
|
+
*
|
|
4
|
+
* Provides utilities for creating isolated users with the same
|
|
5
|
+
* group memberships as the current user. This enables true user
|
|
6
|
+
* isolation while preserving access to sudo, docker, and other
|
|
7
|
+
* privileged groups.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { execSync, spawnSync } = require('child_process');
|
|
11
|
+
|
|
12
|
+
// Debug mode from environment
|
|
13
|
+
const DEBUG =
|
|
14
|
+
process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get the current user's username
|
|
18
|
+
* @returns {string} Current username
|
|
19
|
+
*/
|
|
20
|
+
function getCurrentUser() {
|
|
21
|
+
try {
|
|
22
|
+
return execSync('whoami', { encoding: 'utf8' }).trim();
|
|
23
|
+
} catch {
|
|
24
|
+
return process.env.USER || process.env.USERNAME || 'unknown';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get the groups the current user belongs to
|
|
30
|
+
* @returns {string[]} Array of group names
|
|
31
|
+
*/
|
|
32
|
+
function getCurrentUserGroups() {
|
|
33
|
+
try {
|
|
34
|
+
// Get groups for the current user
|
|
35
|
+
const output = execSync('groups', { encoding: 'utf8' }).trim();
|
|
36
|
+
// Output format: "user : group1 group2 group3" or "group1 group2 group3"
|
|
37
|
+
const parts = output.split(':');
|
|
38
|
+
const groupsPart = parts.length > 1 ? parts[1] : parts[0];
|
|
39
|
+
return groupsPart.trim().split(/\s+/).filter(Boolean);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (DEBUG) {
|
|
42
|
+
console.log(`[DEBUG] Failed to get user groups: ${err.message}`);
|
|
43
|
+
}
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Check if a user exists on the system
|
|
50
|
+
* @param {string} username - Username to check
|
|
51
|
+
* @returns {boolean} True if user exists
|
|
52
|
+
*/
|
|
53
|
+
function userExists(username) {
|
|
54
|
+
try {
|
|
55
|
+
execSync(`id ${username}`, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
56
|
+
return true;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Check if a group exists on the system
|
|
64
|
+
* @param {string} groupname - Group name to check
|
|
65
|
+
* @returns {boolean} True if group exists
|
|
66
|
+
*/
|
|
67
|
+
function groupExists(groupname) {
|
|
68
|
+
try {
|
|
69
|
+
execSync(`getent group ${groupname}`, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Generate a unique username for isolation
|
|
78
|
+
* @param {string} [prefix='start'] - Prefix for the username
|
|
79
|
+
* @returns {string} Generated username
|
|
80
|
+
*/
|
|
81
|
+
function generateIsolatedUsername(prefix = 'start') {
|
|
82
|
+
const timestamp = Date.now().toString(36);
|
|
83
|
+
const random = Math.random().toString(36).substring(2, 6);
|
|
84
|
+
// Keep username short (max 32 chars on most systems)
|
|
85
|
+
// and valid (only alphanumeric, hyphen, underscore)
|
|
86
|
+
return `${prefix}-${timestamp}${random}`.substring(0, 31);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Create a new user with specified groups
|
|
91
|
+
* Requires sudo access
|
|
92
|
+
*
|
|
93
|
+
* @param {string} username - Username to create
|
|
94
|
+
* @param {string[]} groups - Groups to add user to
|
|
95
|
+
* @param {object} options - Options
|
|
96
|
+
* @param {boolean} options.noLogin - If true, create user with nologin shell
|
|
97
|
+
* @param {string} options.homeDir - Home directory (default: /home/username)
|
|
98
|
+
* @returns {{success: boolean, message: string, username: string}}
|
|
99
|
+
*/
|
|
100
|
+
function createUser(username, groups = [], options = {}) {
|
|
101
|
+
if (process.platform === 'win32') {
|
|
102
|
+
return {
|
|
103
|
+
success: false,
|
|
104
|
+
message: 'User creation is not supported on Windows',
|
|
105
|
+
username,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (userExists(username)) {
|
|
110
|
+
return {
|
|
111
|
+
success: true,
|
|
112
|
+
message: `User "${username}" already exists`,
|
|
113
|
+
username,
|
|
114
|
+
alreadyExists: true,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
// Build useradd command
|
|
120
|
+
const useradd = ['sudo', '-n', 'useradd'];
|
|
121
|
+
|
|
122
|
+
// Add home directory option
|
|
123
|
+
if (options.homeDir) {
|
|
124
|
+
useradd.push('-d', options.homeDir);
|
|
125
|
+
}
|
|
126
|
+
useradd.push('-m'); // Create home directory
|
|
127
|
+
|
|
128
|
+
// Add shell option
|
|
129
|
+
if (options.noLogin) {
|
|
130
|
+
useradd.push('-s', '/usr/sbin/nologin');
|
|
131
|
+
} else {
|
|
132
|
+
useradd.push('-s', '/bin/bash');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Filter groups to only existing ones
|
|
136
|
+
const existingGroups = groups.filter(groupExists);
|
|
137
|
+
if (existingGroups.length > 0) {
|
|
138
|
+
// Add user to groups (comma-separated)
|
|
139
|
+
useradd.push('-G', existingGroups.join(','));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Add username
|
|
143
|
+
useradd.push(username);
|
|
144
|
+
|
|
145
|
+
if (DEBUG) {
|
|
146
|
+
console.log(`[DEBUG] Creating user: ${useradd.join(' ')}`);
|
|
147
|
+
console.log(`[DEBUG] Groups to add: ${existingGroups.join(', ')}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const result = spawnSync(useradd[0], useradd.slice(1), {
|
|
151
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
152
|
+
encoding: 'utf8',
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (result.status !== 0) {
|
|
156
|
+
const stderr = result.stderr || '';
|
|
157
|
+
return {
|
|
158
|
+
success: false,
|
|
159
|
+
message: `Failed to create user: ${stderr.trim() || 'Unknown error'}`,
|
|
160
|
+
username,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
success: true,
|
|
166
|
+
message: `Created user "${username}" with groups: ${existingGroups.join(', ') || 'none'}`,
|
|
167
|
+
username,
|
|
168
|
+
groups: existingGroups,
|
|
169
|
+
};
|
|
170
|
+
} catch (err) {
|
|
171
|
+
return {
|
|
172
|
+
success: false,
|
|
173
|
+
message: `Failed to create user: ${err.message}`,
|
|
174
|
+
username,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Create an isolated user with the same groups as the current user
|
|
181
|
+
* @param {string} [customUsername] - Optional custom username (auto-generated if not provided)
|
|
182
|
+
* @param {object} options - Options
|
|
183
|
+
* @param {string[]} options.includeGroups - Only include these groups (default: all)
|
|
184
|
+
* @param {string[]} options.excludeGroups - Exclude these groups (default: none)
|
|
185
|
+
* @param {boolean} options.noLogin - Create with nologin shell
|
|
186
|
+
* @returns {{success: boolean, message: string, username: string, groups: string[]}}
|
|
187
|
+
*/
|
|
188
|
+
function createIsolatedUser(customUsername, options = {}) {
|
|
189
|
+
const username = customUsername || generateIsolatedUsername();
|
|
190
|
+
let groups = getCurrentUserGroups();
|
|
191
|
+
|
|
192
|
+
// Filter groups if specified
|
|
193
|
+
if (options.includeGroups && options.includeGroups.length > 0) {
|
|
194
|
+
groups = groups.filter((g) => options.includeGroups.includes(g));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (options.excludeGroups && options.excludeGroups.length > 0) {
|
|
198
|
+
groups = groups.filter((g) => !options.excludeGroups.includes(g));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Important groups for isolation to work properly
|
|
202
|
+
const importantGroups = ['sudo', 'docker', 'wheel', 'admin'];
|
|
203
|
+
const currentUserGroups = getCurrentUserGroups();
|
|
204
|
+
const inheritedImportantGroups = importantGroups.filter((g) =>
|
|
205
|
+
currentUserGroups.includes(g)
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
if (DEBUG) {
|
|
209
|
+
console.log(`[DEBUG] Current user groups: ${currentUserGroups.join(', ')}`);
|
|
210
|
+
console.log(`[DEBUG] Groups to inherit: ${groups.join(', ')}`);
|
|
211
|
+
console.log(
|
|
212
|
+
`[DEBUG] Important groups found: ${inheritedImportantGroups.join(', ')}`
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return createUser(username, groups, options);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Delete a user and optionally their home directory
|
|
221
|
+
* Requires sudo access
|
|
222
|
+
*
|
|
223
|
+
* @param {string} username - Username to delete
|
|
224
|
+
* @param {object} options - Options
|
|
225
|
+
* @param {boolean} options.removeHome - If true, remove home directory
|
|
226
|
+
* @returns {{success: boolean, message: string}}
|
|
227
|
+
*/
|
|
228
|
+
function deleteUser(username, options = {}) {
|
|
229
|
+
if (process.platform === 'win32') {
|
|
230
|
+
return {
|
|
231
|
+
success: false,
|
|
232
|
+
message: 'User deletion is not supported on Windows',
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!userExists(username)) {
|
|
237
|
+
return {
|
|
238
|
+
success: true,
|
|
239
|
+
message: `User "${username}" does not exist`,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
const userdel = ['sudo', '-n', 'userdel'];
|
|
245
|
+
|
|
246
|
+
if (options.removeHome) {
|
|
247
|
+
userdel.push('-r'); // Remove home directory
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
userdel.push(username);
|
|
251
|
+
|
|
252
|
+
if (DEBUG) {
|
|
253
|
+
console.log(`[DEBUG] Deleting user: ${userdel.join(' ')}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const result = spawnSync(userdel[0], userdel.slice(1), {
|
|
257
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
258
|
+
encoding: 'utf8',
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
if (result.status !== 0) {
|
|
262
|
+
const stderr = result.stderr || '';
|
|
263
|
+
return {
|
|
264
|
+
success: false,
|
|
265
|
+
message: `Failed to delete user: ${stderr.trim() || 'Unknown error'}`,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
success: true,
|
|
271
|
+
message: `Deleted user "${username}"`,
|
|
272
|
+
};
|
|
273
|
+
} catch (err) {
|
|
274
|
+
return {
|
|
275
|
+
success: false,
|
|
276
|
+
message: `Failed to delete user: ${err.message}`,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Setup sudoers entry for a user to run as another user without password
|
|
283
|
+
* Requires sudo access
|
|
284
|
+
*
|
|
285
|
+
* @param {string} fromUser - User who will run sudo
|
|
286
|
+
* @param {string} toUser - User to run commands as
|
|
287
|
+
* @returns {{success: boolean, message: string}}
|
|
288
|
+
*/
|
|
289
|
+
function setupSudoersForUser(fromUser, toUser) {
|
|
290
|
+
if (process.platform === 'win32') {
|
|
291
|
+
return {
|
|
292
|
+
success: false,
|
|
293
|
+
message: 'Sudoers configuration is not supported on Windows',
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
// Create a sudoers.d entry for this user pair
|
|
299
|
+
const sudoersFile = `/etc/sudoers.d/start-${fromUser}-${toUser}`;
|
|
300
|
+
const sudoersEntry = `${fromUser} ALL=(${toUser}) NOPASSWD: ALL\n`;
|
|
301
|
+
|
|
302
|
+
// Use visudo -c to validate the entry before writing
|
|
303
|
+
const checkResult = spawnSync(
|
|
304
|
+
'sudo',
|
|
305
|
+
['-n', 'sh', '-c', `echo '${sudoersEntry}' | visudo -c -f -`],
|
|
306
|
+
{
|
|
307
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
308
|
+
encoding: 'utf8',
|
|
309
|
+
}
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
if (checkResult.status !== 0) {
|
|
313
|
+
return {
|
|
314
|
+
success: false,
|
|
315
|
+
message: `Invalid sudoers entry: ${checkResult.stderr}`,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Write the sudoers file
|
|
320
|
+
const writeResult = spawnSync(
|
|
321
|
+
'sudo',
|
|
322
|
+
[
|
|
323
|
+
'-n',
|
|
324
|
+
'sh',
|
|
325
|
+
'-c',
|
|
326
|
+
`echo '${sudoersEntry}' > ${sudoersFile} && chmod 0440 ${sudoersFile}`,
|
|
327
|
+
],
|
|
328
|
+
{
|
|
329
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
330
|
+
encoding: 'utf8',
|
|
331
|
+
}
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
if (writeResult.status !== 0) {
|
|
335
|
+
return {
|
|
336
|
+
success: false,
|
|
337
|
+
message: `Failed to write sudoers file: ${writeResult.stderr}`,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
success: true,
|
|
343
|
+
message: `Created sudoers entry: ${fromUser} can run as ${toUser}`,
|
|
344
|
+
sudoersFile,
|
|
345
|
+
};
|
|
346
|
+
} catch (err) {
|
|
347
|
+
return {
|
|
348
|
+
success: false,
|
|
349
|
+
message: `Failed to setup sudoers: ${err.message}`,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Get information about a user
|
|
356
|
+
* @param {string} username - Username to query
|
|
357
|
+
* @returns {{exists: boolean, uid?: number, gid?: number, groups?: string[], home?: string, shell?: string}}
|
|
358
|
+
*/
|
|
359
|
+
function getUserInfo(username) {
|
|
360
|
+
if (!userExists(username)) {
|
|
361
|
+
return { exists: false };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
const idOutput = execSync(`id ${username}`, { encoding: 'utf8' }).trim();
|
|
366
|
+
// Parse: uid=1000(user) gid=1000(group) groups=1000(group),27(sudo)
|
|
367
|
+
const uidMatch = idOutput.match(/uid=(\d+)/);
|
|
368
|
+
const gidMatch = idOutput.match(/gid=(\d+)/);
|
|
369
|
+
|
|
370
|
+
const groupsOutput = execSync(`groups ${username}`, {
|
|
371
|
+
encoding: 'utf8',
|
|
372
|
+
}).trim();
|
|
373
|
+
const groupsPart = groupsOutput.split(':').pop().trim();
|
|
374
|
+
const groups = groupsPart.split(/\s+/).filter(Boolean);
|
|
375
|
+
|
|
376
|
+
// Get home directory and shell from passwd
|
|
377
|
+
let home, shell;
|
|
378
|
+
try {
|
|
379
|
+
const passwdEntry = execSync(`getent passwd ${username}`, {
|
|
380
|
+
encoding: 'utf8',
|
|
381
|
+
}).trim();
|
|
382
|
+
const parts = passwdEntry.split(':');
|
|
383
|
+
if (parts.length >= 7) {
|
|
384
|
+
home = parts[5];
|
|
385
|
+
shell = parts[6];
|
|
386
|
+
}
|
|
387
|
+
} catch {
|
|
388
|
+
// Ignore if getent fails
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
exists: true,
|
|
393
|
+
uid: uidMatch ? parseInt(uidMatch[1], 10) : undefined,
|
|
394
|
+
gid: gidMatch ? parseInt(gidMatch[1], 10) : undefined,
|
|
395
|
+
groups,
|
|
396
|
+
home,
|
|
397
|
+
shell,
|
|
398
|
+
};
|
|
399
|
+
} catch {
|
|
400
|
+
return { exists: true }; // User exists but couldn't get details
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Check if the current process has sudo access without password
|
|
406
|
+
* @returns {boolean} True if sudo -n works
|
|
407
|
+
*/
|
|
408
|
+
function hasSudoAccess() {
|
|
409
|
+
try {
|
|
410
|
+
execSync('sudo -n true', { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
411
|
+
return true;
|
|
412
|
+
} catch {
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
module.exports = {
|
|
418
|
+
getCurrentUser,
|
|
419
|
+
getCurrentUserGroups,
|
|
420
|
+
userExists,
|
|
421
|
+
groupExists,
|
|
422
|
+
generateIsolatedUsername,
|
|
423
|
+
createUser,
|
|
424
|
+
createIsolatedUser,
|
|
425
|
+
deleteUser,
|
|
426
|
+
setupSudoersForUser,
|
|
427
|
+
getUserInfo,
|
|
428
|
+
hasSudoAccess,
|
|
429
|
+
};
|
package/test/args-parser.test.js
CHANGED
|
@@ -513,3 +513,182 @@ describe('VALID_BACKENDS', () => {
|
|
|
513
513
|
assert.ok(VALID_BACKENDS.includes('docker'));
|
|
514
514
|
});
|
|
515
515
|
});
|
|
516
|
+
|
|
517
|
+
describe('user isolation option', () => {
|
|
518
|
+
it('should parse --isolated-user without value (auto-generated username)', () => {
|
|
519
|
+
const result = parseArgs(['--isolated-user', '--', 'npm', 'test']);
|
|
520
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
521
|
+
assert.strictEqual(result.wrapperOptions.userName, null);
|
|
522
|
+
assert.strictEqual(result.command, 'npm test');
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('should parse --isolated-user with custom username', () => {
|
|
526
|
+
const result = parseArgs([
|
|
527
|
+
'--isolated-user',
|
|
528
|
+
'myrunner',
|
|
529
|
+
'--',
|
|
530
|
+
'npm',
|
|
531
|
+
'test',
|
|
532
|
+
]);
|
|
533
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
534
|
+
assert.strictEqual(result.wrapperOptions.userName, 'myrunner');
|
|
535
|
+
assert.strictEqual(result.command, 'npm test');
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('should parse -u shorthand', () => {
|
|
539
|
+
const result = parseArgs(['-u', '--', 'npm', 'start']);
|
|
540
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
541
|
+
assert.strictEqual(result.wrapperOptions.userName, null);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it('should parse -u with custom username', () => {
|
|
545
|
+
const result = parseArgs(['-u', 'testuser', '--', 'npm', 'start']);
|
|
546
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
547
|
+
assert.strictEqual(result.wrapperOptions.userName, 'testuser');
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('should parse --isolated-user=value format', () => {
|
|
551
|
+
const result = parseArgs([
|
|
552
|
+
'--isolated-user=myrunner',
|
|
553
|
+
'--',
|
|
554
|
+
'npm',
|
|
555
|
+
'start',
|
|
556
|
+
]);
|
|
557
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
558
|
+
assert.strictEqual(result.wrapperOptions.userName, 'myrunner');
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('should work with isolation options', () => {
|
|
562
|
+
const result = parseArgs([
|
|
563
|
+
'--isolated',
|
|
564
|
+
'screen',
|
|
565
|
+
'--isolated-user',
|
|
566
|
+
'testuser',
|
|
567
|
+
'--',
|
|
568
|
+
'npm',
|
|
569
|
+
'start',
|
|
570
|
+
]);
|
|
571
|
+
assert.strictEqual(result.wrapperOptions.isolated, 'screen');
|
|
572
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
573
|
+
assert.strictEqual(result.wrapperOptions.userName, 'testuser');
|
|
574
|
+
assert.strictEqual(result.command, 'npm start');
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('should work without isolation (standalone user isolation)', () => {
|
|
578
|
+
const result = parseArgs(['--isolated-user', '--', 'node', 'server.js']);
|
|
579
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
580
|
+
assert.strictEqual(result.wrapperOptions.isolated, null);
|
|
581
|
+
assert.strictEqual(result.command, 'node server.js');
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it('should accept valid usernames', () => {
|
|
585
|
+
const validUsernames = [
|
|
586
|
+
'john',
|
|
587
|
+
'www-data',
|
|
588
|
+
'user123',
|
|
589
|
+
'john-doe',
|
|
590
|
+
'user_1',
|
|
591
|
+
];
|
|
592
|
+
for (const username of validUsernames) {
|
|
593
|
+
assert.doesNotThrow(() => {
|
|
594
|
+
parseArgs(['--isolated-user', username, '--', 'echo', 'test']);
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it('should reject invalid username formats with --isolated-user=value syntax', () => {
|
|
600
|
+
const invalidUsernames = ['john@doe', 'user.name', 'user/name'];
|
|
601
|
+
for (const username of invalidUsernames) {
|
|
602
|
+
assert.throws(() => {
|
|
603
|
+
parseArgs([`--isolated-user=${username}`, '--', 'echo', 'test']);
|
|
604
|
+
}, /Invalid username format/);
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('should not consume invalid username as argument (treats as command)', () => {
|
|
609
|
+
// When --isolated-user is followed by an invalid username format, it doesn't consume it
|
|
610
|
+
// The invalid username becomes part of the command instead
|
|
611
|
+
const result = parseArgs([
|
|
612
|
+
'--isolated-user',
|
|
613
|
+
'john@doe',
|
|
614
|
+
'--',
|
|
615
|
+
'echo',
|
|
616
|
+
'test',
|
|
617
|
+
]);
|
|
618
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
619
|
+
assert.strictEqual(result.wrapperOptions.userName, null);
|
|
620
|
+
// john@doe is not consumed as username, but the -- separator means it's not in command either
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it('should throw error for user with docker isolation', () => {
|
|
624
|
+
assert.throws(() => {
|
|
625
|
+
parseArgs([
|
|
626
|
+
'--isolated',
|
|
627
|
+
'docker',
|
|
628
|
+
'--image',
|
|
629
|
+
'node:20',
|
|
630
|
+
'--isolated-user',
|
|
631
|
+
'--',
|
|
632
|
+
'npm',
|
|
633
|
+
'install',
|
|
634
|
+
]);
|
|
635
|
+
}, /--isolated-user is not supported with Docker isolation/);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
it('should work with tmux isolation', () => {
|
|
639
|
+
const result = parseArgs([
|
|
640
|
+
'-i',
|
|
641
|
+
'tmux',
|
|
642
|
+
'--isolated-user',
|
|
643
|
+
'testuser',
|
|
644
|
+
'--',
|
|
645
|
+
'npm',
|
|
646
|
+
'test',
|
|
647
|
+
]);
|
|
648
|
+
assert.strictEqual(result.wrapperOptions.isolated, 'tmux');
|
|
649
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
650
|
+
assert.strictEqual(result.wrapperOptions.userName, 'testuser');
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
describe('keep-user option', () => {
|
|
655
|
+
it('should parse --keep-user flag', () => {
|
|
656
|
+
const result = parseArgs([
|
|
657
|
+
'--isolated-user',
|
|
658
|
+
'--keep-user',
|
|
659
|
+
'--',
|
|
660
|
+
'npm',
|
|
661
|
+
'test',
|
|
662
|
+
]);
|
|
663
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
664
|
+
assert.strictEqual(result.wrapperOptions.keepUser, true);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it('should default keepUser to false', () => {
|
|
668
|
+
const result = parseArgs(['--isolated-user', '--', 'npm', 'test']);
|
|
669
|
+
assert.strictEqual(result.wrapperOptions.keepUser, false);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it('should throw error for keep-user without user', () => {
|
|
673
|
+
assert.throws(() => {
|
|
674
|
+
parseArgs(['--keep-user', '--', 'npm', 'test']);
|
|
675
|
+
}, /--keep-user option is only valid with --isolated-user/);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it('should work with user and isolation options', () => {
|
|
679
|
+
const result = parseArgs([
|
|
680
|
+
'-i',
|
|
681
|
+
'screen',
|
|
682
|
+
'--isolated-user',
|
|
683
|
+
'testuser',
|
|
684
|
+
'--keep-user',
|
|
685
|
+
'--',
|
|
686
|
+
'npm',
|
|
687
|
+
'start',
|
|
688
|
+
]);
|
|
689
|
+
assert.strictEqual(result.wrapperOptions.isolated, 'screen');
|
|
690
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
691
|
+
assert.strictEqual(result.wrapperOptions.userName, 'testuser');
|
|
692
|
+
assert.strictEqual(result.wrapperOptions.keepUser, true);
|
|
693
|
+
});
|
|
694
|
+
});
|
package/test/isolation.test.js
CHANGED
|
@@ -16,6 +16,39 @@ const {
|
|
|
16
16
|
} = require('../src/lib/isolation');
|
|
17
17
|
|
|
18
18
|
describe('Isolation Module', () => {
|
|
19
|
+
describe('wrapCommandWithUser', () => {
|
|
20
|
+
const { wrapCommandWithUser } = require('../src/lib/isolation');
|
|
21
|
+
|
|
22
|
+
it('should return command unchanged when user is null', () => {
|
|
23
|
+
const command = 'echo hello';
|
|
24
|
+
const result = wrapCommandWithUser(command, null);
|
|
25
|
+
assert.strictEqual(result, command);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should wrap command with sudo when user is specified', () => {
|
|
29
|
+
const command = 'echo hello';
|
|
30
|
+
const result = wrapCommandWithUser(command, 'john');
|
|
31
|
+
assert.ok(result.includes('sudo'));
|
|
32
|
+
assert.ok(result.includes('-u john'));
|
|
33
|
+
assert.ok(result.includes('echo hello'));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should escape single quotes in command', () => {
|
|
37
|
+
const command = "echo 'hello'";
|
|
38
|
+
const result = wrapCommandWithUser(command, 'www-data');
|
|
39
|
+
// Should escape quotes properly for shell
|
|
40
|
+
assert.ok(result.includes('sudo'));
|
|
41
|
+
assert.ok(result.includes('-u www-data'));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should use non-interactive sudo', () => {
|
|
45
|
+
const command = 'npm start';
|
|
46
|
+
const result = wrapCommandWithUser(command, 'john');
|
|
47
|
+
// Should include -n flag for non-interactive
|
|
48
|
+
assert.ok(result.includes('sudo -n'));
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
19
52
|
describe('isCommandAvailable', () => {
|
|
20
53
|
it('should return true for common commands (echo)', () => {
|
|
21
54
|
// echo is available on all platforms
|