@remcp/runtime 0.2.105 → 0.2.107
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 +10 -2
- package/package.json +1 -1
- package/src/config.mjs +12 -2
- package/src/documents.mjs +7 -0
- package/src/extended/browser.mjs +134 -14
- package/src/extended/desktop-linux.mjs +80 -15
- package/src/extended/documents.mjs +107 -21
- package/src/helpers/wayland-eis-helper.py +30 -3
- package/src/policy.mjs +42 -9
- package/src/tools/files.mjs +505 -40
- package/src/tools/terminal.mjs +4 -2
- package/src/util.mjs +7 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import process from 'node:process';
|
|
3
|
-
import {
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { constants } from 'node:fs';
|
|
5
|
+
import { copyFile, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
6
|
import { readDocxText, readPdfText } from '../documents.mjs';
|
|
5
7
|
import { resolveSafePath, text } from '../util.mjs';
|
|
6
8
|
import {
|
|
@@ -26,6 +28,51 @@ function xmlUnescape(value) {
|
|
|
26
28
|
|
|
27
29
|
const MAX_OOXML_ENTRIES = 10_000;
|
|
28
30
|
const MAX_OOXML_UNCOMPRESSED_BYTES = 512 * 1024 * 1024;
|
|
31
|
+
const MAX_DOCUMENT_BYTES = 128 * 1024 * 1024;
|
|
32
|
+
|
|
33
|
+
async function readDocumentFile(filePath) {
|
|
34
|
+
const info = await lstat(filePath);
|
|
35
|
+
if (!info.isFile()) throw new Error('Document path must be a regular file');
|
|
36
|
+
if (info.size > MAX_DOCUMENT_BYTES) throw new Error(`Document is too large (maximum ${MAX_DOCUMENT_BYTES} bytes)`);
|
|
37
|
+
return readFile(filePath);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function copyRegularNoFollow(source, destination) {
|
|
41
|
+
const sourceHandle = await open(source, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
42
|
+
try {
|
|
43
|
+
const info = await sourceHandle.stat();
|
|
44
|
+
if (!info.isFile()) throw new Error('Document path must be a regular file');
|
|
45
|
+
if (info.size > MAX_DOCUMENT_BYTES) throw new Error(`Document is too large (maximum ${MAX_DOCUMENT_BYTES} bytes)`);
|
|
46
|
+
const destinationHandle = await open(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
47
|
+
try {
|
|
48
|
+
await destinationHandle.writeFile(await sourceHandle.readFile());
|
|
49
|
+
} finally {
|
|
50
|
+
await destinationHandle.close();
|
|
51
|
+
}
|
|
52
|
+
} finally {
|
|
53
|
+
await sourceHandle.close();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function assertSafeExtractedTree(root, state = { entries: 0 }) {
|
|
58
|
+
const info = await lstat(root);
|
|
59
|
+
if (info.isSymbolicLink()) throw new Error('OOXML archive contains a symbolic link');
|
|
60
|
+
if (!info.isDirectory() && !info.isFile()) throw new Error('OOXML archive contains a special file');
|
|
61
|
+
state.entries += 1;
|
|
62
|
+
if (state.entries > MAX_OOXML_ENTRIES) throw new Error('OOXML archive has too many extracted entries');
|
|
63
|
+
if (!info.isDirectory()) return;
|
|
64
|
+
for (const entry of await readdir(root, { withFileTypes: true })) {
|
|
65
|
+
await assertSafeExtractedTree(path.join(root, entry.name), state);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function safePdfPattern(value, fallback) {
|
|
70
|
+
const pattern = String(value || fallback);
|
|
71
|
+
if (!pattern || pattern.length > 240 || pattern.includes('\0') || path.isAbsolute(pattern) || /[\\/]/.test(pattern) || pattern.split(/[\\/]/).includes('..')) {
|
|
72
|
+
throw new Error('PDF split pattern must be a filename without path separators');
|
|
73
|
+
}
|
|
74
|
+
return pattern;
|
|
75
|
+
}
|
|
29
76
|
|
|
30
77
|
function safeArchiveEntry(name) {
|
|
31
78
|
const normalized = String(name || '').replace(/\\/g, '/');
|
|
@@ -51,14 +98,22 @@ async function validateZipArchive(source) {
|
|
|
51
98
|
}
|
|
52
99
|
|
|
53
100
|
async function extractZip(source, destination) {
|
|
54
|
-
await
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
await
|
|
58
|
-
|
|
101
|
+
const sourceDirectory = await tempDir('remcp-ooxml-source-');
|
|
102
|
+
const sourceCopy = path.join(sourceDirectory, 'source.zip');
|
|
103
|
+
try {
|
|
104
|
+
await copyRegularNoFollow(source, sourceCopy);
|
|
105
|
+
await validateZipArchive(sourceCopy);
|
|
106
|
+
await mkdir(destination, { recursive:true });
|
|
107
|
+
if (process.platform === 'win32') {
|
|
108
|
+
await runPowerShell(`Add-Type -AssemblyName System.IO.Compression.FileSystem;[IO.Compression.ZipFile]::ExtractToDirectory('${escapePowerShellSingle(sourceCopy)}','${escapePowerShellSingle(destination)}')`, { label:'extract OOXML archive', timeout:30_000 });
|
|
109
|
+
} else {
|
|
110
|
+
if (!commandExists('unzip')) unavailable('OOXML document editing', 'unzip is required');
|
|
111
|
+
await runFile('unzip', ['-qq',sourceCopy,'-d',destination], { label:'extract OOXML archive', timeout:30_000 });
|
|
112
|
+
}
|
|
113
|
+
await assertSafeExtractedTree(destination);
|
|
114
|
+
} finally {
|
|
115
|
+
await removeTemp(sourceDirectory);
|
|
59
116
|
}
|
|
60
|
-
if (!commandExists('unzip')) unavailable('OOXML document editing', 'unzip is required');
|
|
61
|
-
await runFile('unzip', ['-qq',source,'-d',destination], { label:'extract OOXML archive', timeout:30_000 });
|
|
62
117
|
}
|
|
63
118
|
|
|
64
119
|
async function createZip(sourceDir, destination) {
|
|
@@ -71,6 +126,26 @@ async function createZip(sourceDir, destination) {
|
|
|
71
126
|
await runFile('zip', ['-qr',destination,'.'], { cwd:sourceDir, label:'create OOXML archive', timeout:30_000 });
|
|
72
127
|
}
|
|
73
128
|
|
|
129
|
+
async function commitDocumentOutput(staged, destination) {
|
|
130
|
+
try {
|
|
131
|
+
await rename(staged, destination);
|
|
132
|
+
return;
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (!['EEXIST', 'EPERM', 'ENOTEMPTY'].includes(error?.code)) throw error;
|
|
135
|
+
}
|
|
136
|
+
const backup = `${destination}.remcp-old-${randomUUID()}`;
|
|
137
|
+
let moved = false;
|
|
138
|
+
try {
|
|
139
|
+
await rename(destination, backup);
|
|
140
|
+
moved = true;
|
|
141
|
+
await rename(staged, destination);
|
|
142
|
+
await rm(backup, { force: true }).catch(() => {});
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (moved && !await lstat(destination).catch(() => null)) await rename(backup, destination).catch(() => {});
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
74
149
|
async function ensureCreateTargetIsNew(filePath) {
|
|
75
150
|
try {
|
|
76
151
|
await stat(filePath);
|
|
@@ -188,7 +263,7 @@ async function readPdfDocument(filePath, data) {
|
|
|
188
263
|
export async function readDocument(args) {
|
|
189
264
|
const filePath = await resolveSafePath(args.path,'path');
|
|
190
265
|
const lower = filePath.toLowerCase();
|
|
191
|
-
const data = await
|
|
266
|
+
const data = await readDocumentFile(filePath);
|
|
192
267
|
if (lower.endsWith('.docx')) return text(readDocxText(data));
|
|
193
268
|
if (lower.endsWith('.pdf')) return text(await readPdfDocument(filePath,data));
|
|
194
269
|
if (lower.endsWith('.xlsx')) return jsonResult(await readXlsx(filePath,args));
|
|
@@ -275,8 +350,12 @@ export async function editSpreadsheet(args) {
|
|
|
275
350
|
const edits = expandSpreadsheetEdits(requestedEdits);
|
|
276
351
|
const output = args.output ? await resolveSafePath(args.output,'output') : filePath;
|
|
277
352
|
const dir = await tempDir(create ? 'remcp-xlsx-create-' : 'remcp-xlsx-edit-');
|
|
278
|
-
|
|
353
|
+
let outputStage = '';
|
|
354
|
+
let tempOut = '';
|
|
279
355
|
try {
|
|
356
|
+
await mkdir(path.dirname(output), { recursive:true });
|
|
357
|
+
outputStage = await mkdtemp(path.join(path.dirname(output), '.remcp-xlsx-output-'));
|
|
358
|
+
tempOut = path.join(outputStage, path.basename(output));
|
|
280
359
|
if (create) {
|
|
281
360
|
await ensureCreateTargetIsNew(filePath);
|
|
282
361
|
await createBlankXlsxTree(dir, optionalString(args.sheet));
|
|
@@ -290,12 +369,12 @@ export async function editSpreadsheet(args) {
|
|
|
290
369
|
let xml = await readFile(worksheetPath,'utf8');
|
|
291
370
|
for (const edit of edits) xml = setWorksheetCell(xml,String(edit.cell || '').toUpperCase(),edit.value,edit.formula);
|
|
292
371
|
await writeFile(worksheetPath,xml,'utf8');
|
|
293
|
-
await mkdir(path.dirname(output), { recursive:true });
|
|
294
372
|
await createZip(dir,tempOut);
|
|
295
|
-
await
|
|
373
|
+
await commitDocumentOutput(tempOut,output);
|
|
296
374
|
return jsonResult({ path:output, sheet:sheet.name, edited_cells:edits.length, bytes:(await stat(output)).size, created:create });
|
|
297
375
|
} finally {
|
|
298
|
-
await rm(tempOut,{force:true}).catch(() => {});
|
|
376
|
+
if (tempOut) await rm(tempOut,{force:true}).catch(() => {});
|
|
377
|
+
if (outputStage) await rm(outputStage,{recursive:true, force:true}).catch(() => {});
|
|
299
378
|
await removeTemp(dir);
|
|
300
379
|
}
|
|
301
380
|
}
|
|
@@ -356,9 +435,13 @@ export async function editDocument(args) {
|
|
|
356
435
|
if (!operations.length || operations.length > 100) throw new Error('operations must contain 1..100 edits');
|
|
357
436
|
const output = args.output ? await resolveSafePath(args.output,'output') : filePath;
|
|
358
437
|
const dir = await tempDir(create ? 'remcp-docx-create-' : 'remcp-docx-edit-');
|
|
359
|
-
|
|
438
|
+
let outputStage = '';
|
|
439
|
+
let tempOut = '';
|
|
360
440
|
let changes = 0;
|
|
361
441
|
try {
|
|
442
|
+
await mkdir(path.dirname(output), { recursive:true });
|
|
443
|
+
outputStage = await mkdtemp(path.join(path.dirname(output), '.remcp-docx-output-'));
|
|
444
|
+
tempOut = path.join(outputStage, path.basename(output));
|
|
362
445
|
if (create) {
|
|
363
446
|
await ensureCreateTargetIsNew(filePath);
|
|
364
447
|
await createBlankDocxTree(dir);
|
|
@@ -383,12 +466,12 @@ export async function editDocument(args) {
|
|
|
383
466
|
}
|
|
384
467
|
}
|
|
385
468
|
await writeFile(documentPath,xml,'utf8');
|
|
386
|
-
await mkdir(path.dirname(output), { recursive:true });
|
|
387
469
|
await createZip(dir,tempOut);
|
|
388
|
-
await
|
|
470
|
+
await commitDocumentOutput(tempOut,output);
|
|
389
471
|
return jsonResult({ path:output, operations:operations.length, changes, bytes:(await stat(output)).size, created:create });
|
|
390
472
|
} finally {
|
|
391
|
-
await rm(tempOut,{force:true}).catch(() => {});
|
|
473
|
+
if (tempOut) await rm(tempOut,{force:true}).catch(() => {});
|
|
474
|
+
if (outputStage) await rm(outputStage,{recursive:true, force:true}).catch(() => {});
|
|
392
475
|
await removeTemp(dir);
|
|
393
476
|
}
|
|
394
477
|
}
|
|
@@ -437,12 +520,12 @@ export async function pdfAction(args) {
|
|
|
437
520
|
const action = requireEnum(args.action,'action',['merge','split','extract_pages','annotations','info']);
|
|
438
521
|
if (action === 'annotations') {
|
|
439
522
|
const source = await resolveSafePath(args.path,'path');
|
|
440
|
-
return jsonResult({ path:source, annotations:pdfAnnotations(await
|
|
523
|
+
return jsonResult({ path:source, annotations:pdfAnnotations(await readDocumentFile(source)) });
|
|
441
524
|
}
|
|
442
525
|
if (action === 'info') {
|
|
443
526
|
const source = await resolveSafePath(args.path,'path');
|
|
444
527
|
if (commandExists('pdfinfo')) return text((await runFile('pdfinfo',[source],{label:'pdfinfo'})).stdout);
|
|
445
|
-
const data = await
|
|
528
|
+
const data = await readDocumentFile(source); return jsonResult({ path:source, bytes:data.length, annotation_count:pdfAnnotations(data).length });
|
|
446
529
|
}
|
|
447
530
|
if (action === 'merge') {
|
|
448
531
|
const requested = Array.isArray(args.paths) ? args.paths : [];
|
|
@@ -456,8 +539,11 @@ export async function pdfAction(args) {
|
|
|
456
539
|
}
|
|
457
540
|
const source=await resolveSafePath(args.path,'path');
|
|
458
541
|
if(action==='split'){
|
|
459
|
-
const
|
|
460
|
-
|
|
542
|
+
const requestedOutputDir=await resolveSafePath(args.output_dir || path.dirname(source),'output_dir');
|
|
543
|
+
await mkdir(requestedOutputDir,{recursive:true});
|
|
544
|
+
const outputDir=await resolveSafePath(requestedOutputDir,'output_dir');
|
|
545
|
+
const patternName=safePdfPattern(optionalString(args.pattern),`${path.basename(source,path.extname(source))}-%d.pdf`);
|
|
546
|
+
const pattern=path.join(outputDir,patternName);
|
|
461
547
|
if(commandExists('pdfseparate')) await runFile('pdfseparate',[source,pattern],{label:'PDF split',timeout:60_000});
|
|
462
548
|
else unavailable('PDF split','install poppler-utils (pdfseparate)');
|
|
463
549
|
return jsonResult({action,output_dir:outputDir,files:(await readdir(outputDir)).filter(name=>name.toLowerCase().endsWith('.pdf')).sort()});
|
|
@@ -248,6 +248,9 @@ class EiSender:
|
|
|
248
248
|
self.connected = False
|
|
249
249
|
self.pointer_device_key = None
|
|
250
250
|
self.pointer_position = None
|
|
251
|
+
# A press/release pair must stay on the same emulated device even if
|
|
252
|
+
# absolute motion crosses an EIS region and changes pointer_device_key.
|
|
253
|
+
self.button_device_keys = {}
|
|
251
254
|
|
|
252
255
|
def _device_key(self, ptr):
|
|
253
256
|
return int(ptr or 0)
|
|
@@ -267,6 +270,9 @@ class EiSender:
|
|
|
267
270
|
if key == self.pointer_device_key:
|
|
268
271
|
self.pointer_device_key = None
|
|
269
272
|
self.pointer_position = None
|
|
273
|
+
for button, device_key in list(self.button_device_keys.items()):
|
|
274
|
+
if device_key == key:
|
|
275
|
+
self.button_device_keys.pop(button, None)
|
|
270
276
|
if state:
|
|
271
277
|
if state["emulating"]:
|
|
272
278
|
try:
|
|
@@ -313,10 +319,14 @@ class EiSender:
|
|
|
313
319
|
state["emulating"] = True
|
|
314
320
|
elif event_type == EI_EVENT_DEVICE_PAUSED:
|
|
315
321
|
device = self.lib.ei_event_get_device(event)
|
|
316
|
-
|
|
322
|
+
key = self._device_key(device)
|
|
323
|
+
state = self.devices.get(key)
|
|
317
324
|
if state:
|
|
318
325
|
state["resumed"] = False
|
|
319
326
|
state["emulating"] = False
|
|
327
|
+
for button, device_key in list(self.button_device_keys.items()):
|
|
328
|
+
if device_key == key:
|
|
329
|
+
self.button_device_keys.pop(button, None)
|
|
320
330
|
elif event_type == EI_EVENT_DEVICE_REMOVED:
|
|
321
331
|
self._drop_device(self.lib.ei_event_get_device(event))
|
|
322
332
|
finally:
|
|
@@ -419,8 +429,24 @@ class EiSender:
|
|
|
419
429
|
self.remember_pointer(device, (x, y))
|
|
420
430
|
self.frame(device)
|
|
421
431
|
elif op == "button":
|
|
422
|
-
|
|
423
|
-
|
|
432
|
+
button = int(payload["button"])
|
|
433
|
+
pressed = bool(payload.get("pressed"))
|
|
434
|
+
if pressed:
|
|
435
|
+
device = self.device_for_pointer_context(EI_CAP_BUTTON)
|
|
436
|
+
self.button_device_keys[button] = self._device_key(device)
|
|
437
|
+
else:
|
|
438
|
+
key = self.button_device_keys.pop(button, None)
|
|
439
|
+
state = self.devices.get(key) if key else None
|
|
440
|
+
if (
|
|
441
|
+
state
|
|
442
|
+
and state["resumed"]
|
|
443
|
+
and state["emulating"]
|
|
444
|
+
and self.lib.ei_device_has_capability(state["ptr"], EI_CAP_BUTTON)
|
|
445
|
+
):
|
|
446
|
+
device = state["ptr"]
|
|
447
|
+
else:
|
|
448
|
+
device = self.device_for_pointer_context(EI_CAP_BUTTON)
|
|
449
|
+
self.lib.ei_device_button_button(device, button, pressed)
|
|
424
450
|
self.frame(device)
|
|
425
451
|
elif op == "scroll":
|
|
426
452
|
device = self.device_for_pointer_context(EI_CAP_SCROLL)
|
|
@@ -446,6 +472,7 @@ class EiSender:
|
|
|
446
472
|
except Exception:
|
|
447
473
|
pass
|
|
448
474
|
self.devices.clear()
|
|
475
|
+
self.button_device_keys.clear()
|
|
449
476
|
if self.ei:
|
|
450
477
|
self.lib.ei_unref(self.ei)
|
|
451
478
|
self.ei = None
|
package/src/policy.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
1
2
|
import { runtimeConfig } from './config.mjs';
|
|
2
3
|
import { recordEvent } from './telemetry.mjs';
|
|
3
4
|
import { fail } from './util.mjs';
|
|
@@ -10,7 +11,10 @@ import { fail } from './util.mjs';
|
|
|
10
11
|
// Rules are matched against the *command word* of each shell segment, so a read-only
|
|
11
12
|
// command that merely mentions a dangerous word (`grep -n format README.md`,
|
|
12
13
|
// `cat notes/shutdown.md`) is not refused.
|
|
13
|
-
const WRAPPERS = new Set(['sudo', 'doas', 'env', 'nohup', 'command', 'builtin', 'time', 'nice', 'ionice', 'stdbuf', 'timeout', 'setsid', 'exec']);
|
|
14
|
+
const WRAPPERS = new Set(['sudo', 'doas', 'env', 'nohup', 'command', 'builtin', 'time', 'nice', 'ionice', 'stdbuf', 'timeout', 'setsid', 'exec', 'busybox']);
|
|
15
|
+
const SHELL_WRAPPERS = /^(?:sh|bash|zsh|dash|ksh|fish|cmd(?:\.exe)?|powershell(?:\.exe)?|pwsh(?:\.exe)?)$/i;
|
|
16
|
+
const INTERPRETER_EXECUTION = /\b(?:os\.system|subprocess\.(?:run|call|check_call|check_output|Popen)|child_process|\bexec\s*\(|\beval\s*\(|\bspawn\s*\()/i;
|
|
17
|
+
const CATASTROPHIC_TEXT = /(?:mkfs(?:\.[a-z0-9]+)?\b|\b(?:fdisk|sfdisk|cfdisk|parted|diskpart|gdisk)\b|\bdd\b[^\n]*(?:\bof=\s*\/dev\/|\bof=\s*\\\\\.\\)|\b(?:shutdown|reboot|halt|poweroff|restart-computer|stop-computer)\b|\brm\b[^\n]*(?:--recursive|--force|-[a-z]*r[a-z]*f[a-z]*|-[a-z]*f[a-z]*r[a-z]*)[^\n]*(?:\s|\s*\/\s*|\s*~\s*|\s*\$HOME\b|\s*\$\{HOME\})|\bpython[0-9.]*\b[^\n]*(?:os\.system|subprocess|rm\s+-[^\n]*r[^\n]*f))/i;
|
|
14
18
|
|
|
15
19
|
const DANGEROUS_RULES = [
|
|
16
20
|
{ id: 'filesystem-format', description: 'formats a filesystem', commands: /^mkfs(\.[a-z0-9]+)?$/i, args: () => true },
|
|
@@ -18,15 +22,19 @@ const DANGEROUS_RULES = [
|
|
|
18
22
|
{ id: 'disk-partition', description: 'repartitions a disk', commands: /^(fdisk|sfdisk|cfdisk|parted|diskpart|gdisk)$/i, args: () => true },
|
|
19
23
|
{ id: 'redirect-to-device', description: 'redirects output into a block device', commands: null, anySegment: />>?\s*\/dev\/(sd|hd|vd|nvme|mmcblk|disk)/i },
|
|
20
24
|
{ id: 'host-power', description: 'powers off or reboots the machine', commands: /^(shutdown|reboot|halt|poweroff|restart-computer|stop-computer)$/i, args: () => true },
|
|
25
|
+
{ id: 'service-power', description: 'changes system power through a service manager', commands: /^systemctl$/i, args: segment => /\b(?:poweroff|reboot|halt|shutdown)\b/i.test(segment) },
|
|
21
26
|
{ id: 'init-runlevel', description: 'changes the init runlevel', commands: /^init$/i, args: segment => /\binit\s+[06]\b/i.test(segment) },
|
|
22
27
|
{ id: 'fork-bomb', description: 'starts a fork bomb', commands: null, anySegment: /:\s*\(\s*\)\s*\{[^}]*\}\s*;\s*:/ },
|
|
23
28
|
{
|
|
24
29
|
id: 'recursive-root-delete',
|
|
25
30
|
description: 'recursively deletes a root or home path',
|
|
26
31
|
commands: /^rm$/i,
|
|
27
|
-
args: segment =>
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
args: segment => {
|
|
33
|
+
const recursive = /(^|\s)(?:--recursive|-[a-z]*r[a-z]*)(?=\s|$)/i.test(segment);
|
|
34
|
+
const force = /(^|\s)(?:--force|-[a-z]*f[a-z]*)(?=\s|$)/i.test(segment);
|
|
35
|
+
const rootTarget = /(\s)(\/|\/\*|~|~\/\*|\$HOME|\$\{HOME\})(\s|$)/i.test(segment);
|
|
36
|
+
return recursive && force && rootTarget;
|
|
37
|
+
},
|
|
30
38
|
},
|
|
31
39
|
{ id: 'chmod-root', description: 'recursively rewrites permissions on a root path', commands: /^chmod$/i, args: segment => /(\s)(\/|\/\*)(\s|$)/.test(segment) },
|
|
32
40
|
{ id: 'chown-root', description: 'recursively rewrites ownership on a root path', commands: /^chown$/i, args: segment => /(\s)(\/|\/\*)(\s|$)/.test(segment) },
|
|
@@ -39,6 +47,10 @@ function normalize(command) {
|
|
|
39
47
|
return String(command).replace(/\s+/g, ' ').trim();
|
|
40
48
|
}
|
|
41
49
|
|
|
50
|
+
function matchingText(value) {
|
|
51
|
+
return String(value).replace(/(["'])(.*?)\1/g, '$2').replace(/\\(["'\\$`])/g, '$1');
|
|
52
|
+
}
|
|
53
|
+
|
|
42
54
|
// Split on shell control operators so each simple command is judged on its own command
|
|
43
55
|
// word instead of on every word in the line.
|
|
44
56
|
function segments(command) {
|
|
@@ -49,14 +61,15 @@ function segments(command) {
|
|
|
49
61
|
}
|
|
50
62
|
|
|
51
63
|
function commandWord(segment) {
|
|
52
|
-
const tokens = segment.split(' ').filter(Boolean);
|
|
64
|
+
const tokens = matchingText(segment).split(' ').filter(Boolean);
|
|
53
65
|
while (tokens.length) {
|
|
54
66
|
const token = tokens[0];
|
|
55
67
|
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) { tokens.shift(); continue; }
|
|
56
|
-
|
|
68
|
+
const tokenName = path.basename(token).replace(/\.(exe|cmd|bat)$/i, '').toLowerCase();
|
|
69
|
+
if (WRAPPERS.has(tokenName)) {
|
|
57
70
|
tokens.shift();
|
|
58
71
|
while (tokens.length && tokens[0].startsWith('-')) tokens.shift();
|
|
59
|
-
if (
|
|
72
|
+
if (tokenName === 'timeout' && tokens.length && /^\d+[smhd]?$/.test(tokens[0])) tokens.shift();
|
|
60
73
|
continue;
|
|
61
74
|
}
|
|
62
75
|
break;
|
|
@@ -65,22 +78,42 @@ function commandWord(segment) {
|
|
|
65
78
|
return word.replace(/^.*[\\/]/, '').replace(/\.(exe|cmd|bat)$/i, '');
|
|
66
79
|
}
|
|
67
80
|
|
|
81
|
+
function nestedCommandValues(command) {
|
|
82
|
+
const values = [];
|
|
83
|
+
const normalized = String(command || '');
|
|
84
|
+
const substitution = /\$\(([\s\S]*?)\)|`([^`]*)`/g;
|
|
85
|
+
for (const match of normalized.matchAll(substitution)) values.push(match[1] || match[2] || '');
|
|
86
|
+
const shellCommand = /(^|\s)(?:-c|--command|-e|--eval)\s+(?:"([\s\S]*?)"|'([\s\S]*?)'|([^\s;]+))/g;
|
|
87
|
+
for (const match of normalized.matchAll(shellCommand)) values.push(match[2] || match[3] || match[4] || '');
|
|
88
|
+
return values;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function nestedExecutionIsDangerous(command) {
|
|
92
|
+
const normalized = String(command || '');
|
|
93
|
+
const shell = normalized.split(/\s+/).some(token => SHELL_WRAPPERS.test(path.basename(token).replace(/\.(exe|cmd|bat)$/i, '')));
|
|
94
|
+
return (shell || INTERPRETER_EXECUTION.test(normalized)) && CATASTROPHIC_TEXT.test(normalized);
|
|
95
|
+
}
|
|
96
|
+
|
|
68
97
|
export function checkCommand(command) {
|
|
69
98
|
if (runtimeConfig.unrestricted) return { warned: false, mode: 'allow', unrestricted: true, findings: [] };
|
|
70
99
|
const normalized = normalize(command);
|
|
71
|
-
const
|
|
100
|
+
const nested = nestedCommandValues(normalized);
|
|
101
|
+
const commands = [normalized, ...nested];
|
|
102
|
+
const parts = commands.flatMap(segments);
|
|
72
103
|
const findings = [];
|
|
73
104
|
for (const blocked of runtimeConfig.blockedCommands) {
|
|
74
105
|
if (normalized.toLowerCase().includes(blocked.toLowerCase())) findings.push({ id: 'policy', description: blocked, source: 'device-policy' });
|
|
75
106
|
}
|
|
76
107
|
if (runtimeConfig.dangerousCommands !== 'allow') {
|
|
77
108
|
for (const part of parts) {
|
|
109
|
+
const matchText = matchingText(part);
|
|
78
110
|
const word = commandWord(part);
|
|
79
111
|
for (const rule of DANGEROUS_RULES) {
|
|
80
|
-
const matches = rule.anySegment ? rule.anySegment.test(
|
|
112
|
+
const matches = rule.anySegment ? rule.anySegment.test(matchText) : Boolean(word && rule.commands?.test(word) && rule.args(matchText));
|
|
81
113
|
if (matches) findings.push({ id: rule.id, description: rule.description, source: 'builtin' });
|
|
82
114
|
}
|
|
83
115
|
}
|
|
116
|
+
if (nestedExecutionIsDangerous(matchingText(normalized))) findings.push({ id: 'nested-catastrophic-command', description: 'executes a catastrophic command through a shell or interpreter', source: 'builtin' });
|
|
84
117
|
}
|
|
85
118
|
if (!findings.length) return { warned: false, mode: runtimeConfig.dangerousCommands };
|
|
86
119
|
const unique = [...new Map(findings.map(item => [item.id + item.description, item])).values()];
|