ssh-mcp 2.19.1 → 2.20.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/build/guard/redactor.js
CHANGED
|
@@ -45,6 +45,11 @@ export function redactText(text, opts = {}) {
|
|
|
45
45
|
return match;
|
|
46
46
|
});
|
|
47
47
|
}
|
|
48
|
+
for (const secret of opts.literalSecrets ?? []) {
|
|
49
|
+
if (secret && secret.length >= 4) {
|
|
50
|
+
result = result.split(secret).join(`[REDACTED:secret:${secret.length}]`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
48
53
|
return result;
|
|
49
54
|
}
|
|
50
55
|
export function redactRecord(obj, opts = {}) {
|
|
@@ -720,6 +720,20 @@ const INTERPRETERS = Object.assign(Object.create(null), {
|
|
|
720
720
|
deno: { programBearingWords: ['eval'], readable: false },
|
|
721
721
|
pwsh: { programBearingWords: ['-c', '-Command', '-e', '-EncodedCommand'], readable: false },
|
|
722
722
|
powershell: { programBearingWords: ['-c', '-Command', '-e', '-EncodedCommand'], readable: false },
|
|
723
|
+
// The same gap as the osascript/lua block above, measured on 2.19.1
|
|
724
|
+
// (GHSA-cm4c-4q74-mgmj): `gdb -ex`, `expect -c` and `vi/vim/ex -c` each
|
|
725
|
+
// hand a program to an executor and each classified `safe` — the one class
|
|
726
|
+
// that never reaches the approval gate. `readable: false` throughout: the
|
|
727
|
+
// program is gdb command language / expect / ex, not shell text, so its
|
|
728
|
+
// presence is what counts, exactly like python's `-c`.
|
|
729
|
+
gdb: { programBearingWords: ['-ex', '--eval-command', '-iex', '--init-eval-command'], readable: false },
|
|
730
|
+
expect: { programBearingWords: ['-c'], readable: false },
|
|
731
|
+
vi: { programBearingWords: ['-c', '--cmd'], readable: false },
|
|
732
|
+
vim: { programBearingWords: ['-c', '--cmd'], readable: false },
|
|
733
|
+
ex: { programBearingWords: ['-c', '--cmd'], readable: false },
|
|
734
|
+
view: { programBearingWords: ['-c', '--cmd'], readable: false },
|
|
735
|
+
gvim: { programBearingWords: ['-c', '--cmd'], readable: false },
|
|
736
|
+
vimdiff: { programBearingWords: ['-c', '--cmd'], readable: false },
|
|
723
737
|
});
|
|
724
738
|
/**
|
|
725
739
|
* Every `INTERPRETERS` entry, keyed by its name lower-cased.
|
|
@@ -1184,6 +1198,15 @@ export function nestedCommands(command, speculativeOperands = true, dialect = 'p
|
|
|
1184
1198
|
// `-ok` tokens in 81 bytes cost 17 seconds of a single-threaded event loop.
|
|
1185
1199
|
i = stop === -1 ? words.length : i + 1 + stop;
|
|
1186
1200
|
}
|
|
1201
|
+
// Two carriers whose program is embedded inside a single word, as shell
|
|
1202
|
+
// text this file can read, so the carried command is classified as itself
|
|
1203
|
+
// (GHSA-cm4c-4q74-mgmj). The speculative-operand catch-all above cannot
|
|
1204
|
+
// see either one: a `--checkpoint-action=exec=…` word starts with `-`, and
|
|
1205
|
+
// the sqlite3 dot-command is only ever one word to begin with. Both
|
|
1206
|
+
// helpers are head-gated, so an operand that merely mentions the spelling
|
|
1207
|
+
// carries nothing.
|
|
1208
|
+
found.push(...tarCheckpointExecPrograms(words));
|
|
1209
|
+
found.push(...sqliteShellPrograms(words));
|
|
1187
1210
|
}
|
|
1188
1211
|
// A pipe stage whose command word is an interpreter with no program of its own runs
|
|
1189
1212
|
// whatever the previous stage printed. Starts at 1: a leading `|` records its separator
|
|
@@ -1224,6 +1247,11 @@ const EXEC_WRAPPERS = new Set([
|
|
|
1224
1247
|
'timeout', 'chrt', 'taskset', 'xargs', 'watch',
|
|
1225
1248
|
// `busybox <applet>` runs the applet, so it wraps rather than interprets.
|
|
1226
1249
|
'busybox',
|
|
1250
|
+
// Review round (GHSA-cm4c-4q74-mgmj): each of these runs the command it
|
|
1251
|
+
// wraps, and being absent here hid every head-keyed gate behind it —
|
|
1252
|
+
// `strace docker run --privileged …` and `flock make …` classified `safe`.
|
|
1253
|
+
// Also closes the pre-existing `strace sudo id` hole the same way.
|
|
1254
|
+
'strace', 'ltrace', 'valgrind', 'flock', 'chroot', 'perf', 'time',
|
|
1227
1255
|
]);
|
|
1228
1256
|
/**
|
|
1229
1257
|
* Arguments that turn an allowlisted binary into one that writes or executes.
|
|
@@ -1418,6 +1446,190 @@ function elevatedBinaryOf(command, dialect = 'posix') {
|
|
|
1418
1446
|
}
|
|
1419
1447
|
return null;
|
|
1420
1448
|
}
|
|
1449
|
+
/**
|
|
1450
|
+
* sqlite3 dot-commands that hand the rest of their line to `/bin/sh`, and the
|
|
1451
|
+
* shell text they hand over. `.shell id` only executes as ONE argv word — as
|
|
1452
|
+
* two words sqlite reads `.shell` with no argument and errors — so matching
|
|
1453
|
+
* the embedded form is not a shortcut, it is the whole surface
|
|
1454
|
+
* (GHSA-cm4c-4q74-mgmj).
|
|
1455
|
+
*/
|
|
1456
|
+
const SQLITE_SHELL_DOT_COMMAND = /^\.(?:shell|system)\s+(\S.*)$/;
|
|
1457
|
+
/**
|
|
1458
|
+
* Fold a command word for the head-keyed carrier and broker tables below:
|
|
1459
|
+
* strip a Windows executable suffix and fold case, the same normalization
|
|
1460
|
+
* `resolveInterpreter` applies for the interpreter table. This server
|
|
1461
|
+
* advertises Windows hosts, where the same binary arrives as `ssh.exe` or
|
|
1462
|
+
* `Docker`, and a head-keyed table that does not fold is keyed on a spelling
|
|
1463
|
+
* the target may never use — measured in the review round: every gate below
|
|
1464
|
+
* was bypassable with an `.exe` spelling.
|
|
1465
|
+
*/
|
|
1466
|
+
function foldCommandWord(word) {
|
|
1467
|
+
return word.replace(WINDOWS_EXE_SUFFIX, '').toLowerCase();
|
|
1468
|
+
}
|
|
1469
|
+
/** The segment's command word, folded for the tables below, or null. */
|
|
1470
|
+
function foldedHeadOf(words) {
|
|
1471
|
+
const idx = effectiveCommandIndex(words);
|
|
1472
|
+
return idx === -1 ? null : foldCommandWord(stripPath(unquote(words[idx])));
|
|
1473
|
+
}
|
|
1474
|
+
function sqliteShellPrograms(words) {
|
|
1475
|
+
if (foldedHeadOf(words) !== 'sqlite3')
|
|
1476
|
+
return [];
|
|
1477
|
+
const programs = [];
|
|
1478
|
+
for (let i = effectiveCommandIndex(words) + 1; i < words.length; i++) {
|
|
1479
|
+
const match = SQLITE_SHELL_DOT_COMMAND.exec(words[i]);
|
|
1480
|
+
if (match)
|
|
1481
|
+
programs.push(match[1]);
|
|
1482
|
+
}
|
|
1483
|
+
return programs;
|
|
1484
|
+
}
|
|
1485
|
+
/**
|
|
1486
|
+
* GNU tar's `--checkpoint-action=exec=…` hands its value to `sh -c` at every
|
|
1487
|
+
* checkpoint. All three spellings fire (GHSA-cm4c-4q74-mgmj, review round):
|
|
1488
|
+
* attached with any unambiguous `getopt_long` prefix of the option —
|
|
1489
|
+
* `--checkpoint-a` is where it diverges from `--checkpoint`, and the loose
|
|
1490
|
+
* `[a-z-]*` tail over-accepts abbreviations real tar would refuse, which only
|
|
1491
|
+
* classifies a command that cannot run — and detached, the value as the next
|
|
1492
|
+
* argv word, which tar 1.34 measured executing. Head-gated like
|
|
1493
|
+
* `sqliteShellPrograms`, so an `echo` of the flag text is a mention
|
|
1494
|
+
* (the `grep -e`/`echo` false positives of the review round).
|
|
1495
|
+
*/
|
|
1496
|
+
const TAR_CHECKPOINT_FLAG = /^--checkpoint-a[a-z-]*$/;
|
|
1497
|
+
const TAR_CHECKPOINT_EXEC_ATTACHED = /^--checkpoint-a[a-z-]*=exec=(.+)$/;
|
|
1498
|
+
const TAR_CHECKPOINT_ACTION_EXEC = /^exec=(.+)$/;
|
|
1499
|
+
function tarCheckpointExecPrograms(words) {
|
|
1500
|
+
if (foldedHeadOf(words) !== 'tar')
|
|
1501
|
+
return [];
|
|
1502
|
+
const start = effectiveCommandIndex(words) + 1;
|
|
1503
|
+
const programs = [];
|
|
1504
|
+
for (let i = start; i < words.length; i++) {
|
|
1505
|
+
const attached = TAR_CHECKPOINT_EXEC_ATTACHED.exec(words[i]);
|
|
1506
|
+
if (attached) {
|
|
1507
|
+
programs.push(attached[1]);
|
|
1508
|
+
continue;
|
|
1509
|
+
}
|
|
1510
|
+
if (TAR_CHECKPOINT_FLAG.test(words[i])) {
|
|
1511
|
+
const detached = TAR_CHECKPOINT_ACTION_EXEC.exec(words[i + 1] ?? '');
|
|
1512
|
+
if (detached)
|
|
1513
|
+
programs.push(detached[1]);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
return programs;
|
|
1517
|
+
}
|
|
1518
|
+
/**
|
|
1519
|
+
* Builders whose every invocation runs recipes — shell commands read from a
|
|
1520
|
+
* file this classifier never sees. `make deploy` is as arbitrary as
|
|
1521
|
+
* `sh -c '…'`; the Makefile is simply elsewhere (GHSA-cm4c-4q74-mgmj).
|
|
1522
|
+
*/
|
|
1523
|
+
const RECIPE_RUNNERS = new Set(['make', 'gmake']);
|
|
1524
|
+
/** Root user spellings, with or without docker's `user[:group]` suffix. */
|
|
1525
|
+
const ROOT_USERS = new Set(['0', 'root']);
|
|
1526
|
+
/** `0`, `root`, `0:0`, `root:root` — uid 0 however the CLI spells it. */
|
|
1527
|
+
function isRootUserValue(value) {
|
|
1528
|
+
return ROOT_USERS.has(value.split(':')[0]);
|
|
1529
|
+
}
|
|
1530
|
+
/** Docker's own global flags that consume the next word, before the subcommand. */
|
|
1531
|
+
const DOCKER_GLOBAL_VALUE_FLAGS = new Set(['-H', '--host', '--context', '--config', '--log-level', '-l']);
|
|
1532
|
+
/** `--privileged`, `--privileged=true`, `--privileged=1` — and not `=false`/`=0`. */
|
|
1533
|
+
function isPrivilegedFlag(word) {
|
|
1534
|
+
if (word === '--privileged')
|
|
1535
|
+
return true;
|
|
1536
|
+
const value = word.slice('--privileged='.length);
|
|
1537
|
+
return word.startsWith('--privileged=') && (value === 'true' || value === '1');
|
|
1538
|
+
}
|
|
1539
|
+
/**
|
|
1540
|
+
* The privilege brokers: binaries that reach root by their own design, at
|
|
1541
|
+
* their own flag, rather than by carrying another command the way `sudo`
|
|
1542
|
+
* does. `docker run --privileged` and `docker exec -u 0` are uid 0 in the
|
|
1543
|
+
* container, `systemd-run --uid=0` runs the unit as root (and `-p User=root`
|
|
1544
|
+
* is the property spelling of the same choice), and `nsenter -t` runs inside
|
|
1545
|
+
* another process's namespaces — pid 1's are the host's, and text cannot
|
|
1546
|
+
* tell which target an operand names. The `runuser`/`setpriv` answer:
|
|
1547
|
+
* `privileged` (GHSA-cm4c-4q74-mgmj).
|
|
1548
|
+
*
|
|
1549
|
+
* Scoped to the root spellings. Plain `docker run img cmd` and
|
|
1550
|
+
* `docker exec ctr cmd` still run as the image's default user — gating every
|
|
1551
|
+
* container command is a policy decision this fix does not make for the
|
|
1552
|
+
* operator's everyday `docker run -e FOO=bar img`.
|
|
1553
|
+
*/
|
|
1554
|
+
function brokeredElevation(words) {
|
|
1555
|
+
const head = foldedHeadOf(words);
|
|
1556
|
+
if (head === null)
|
|
1557
|
+
return null;
|
|
1558
|
+
const args = words.slice(effectiveCommandIndex(words) + 1);
|
|
1559
|
+
if (head === 'docker') {
|
|
1560
|
+
// Global flags first — an operator aiming at a remote daemon writes
|
|
1561
|
+
// `-H tcp://…` or `--context prod` before the subcommand.
|
|
1562
|
+
let i = 0;
|
|
1563
|
+
while (i < args.length && args[i].startsWith('-')) {
|
|
1564
|
+
i += DOCKER_GLOBAL_VALUE_FLAGS.has(args[i]) ? 2 : 1;
|
|
1565
|
+
}
|
|
1566
|
+
let sub = args[i];
|
|
1567
|
+
let rest = args.slice(i + 1);
|
|
1568
|
+
// `docker container run|exec` is the same subcommand spelled in two words.
|
|
1569
|
+
if (sub === 'container') {
|
|
1570
|
+
sub = rest[0];
|
|
1571
|
+
rest = rest.slice(1);
|
|
1572
|
+
}
|
|
1573
|
+
if (sub !== 'run' && sub !== 'exec')
|
|
1574
|
+
return null;
|
|
1575
|
+
// Deliberately not stopped at the image/container operand: docker's own
|
|
1576
|
+
// value-taking flags (`-e VAR=x`) make that boundary unparseable without
|
|
1577
|
+
// docker's full option table, and a `--user=root` in the container's argv
|
|
1578
|
+
// still names root-seeking behaviour inside it. Over-classifying reaches
|
|
1579
|
+
// an approval prompt; under-classifying reaches none.
|
|
1580
|
+
for (let j = 0; j < rest.length; j++) {
|
|
1581
|
+
const word = rest[j];
|
|
1582
|
+
if (isPrivilegedFlag(word))
|
|
1583
|
+
return 'docker';
|
|
1584
|
+
if ((word === '-u' || word === '--user') && isRootUserValue(rest[j + 1] ?? ''))
|
|
1585
|
+
return 'docker';
|
|
1586
|
+
if (word.startsWith('--user=') && isRootUserValue(word.slice('--user='.length)))
|
|
1587
|
+
return 'docker';
|
|
1588
|
+
// `-u0`, `-u=0`: the value glued to the shorthand, as pflag allows.
|
|
1589
|
+
if (/^-u=?.+/.test(word) && word !== '-u' && isRootUserValue(word.replace(/^-u=?/, '')))
|
|
1590
|
+
return 'docker';
|
|
1591
|
+
}
|
|
1592
|
+
return null;
|
|
1593
|
+
}
|
|
1594
|
+
if (head === 'systemd-run') {
|
|
1595
|
+
for (let j = 0; j < args.length; j++) {
|
|
1596
|
+
const word = args[j];
|
|
1597
|
+
if (word === '--uid' && isRootUserValue(args[j + 1] ?? ''))
|
|
1598
|
+
return 'systemd-run';
|
|
1599
|
+
if (word.startsWith('--uid=') && isRootUserValue(word.slice('--uid='.length)))
|
|
1600
|
+
return 'systemd-run';
|
|
1601
|
+
const property = word === '-p' || word === '--property'
|
|
1602
|
+
? args[j + 1] ?? ''
|
|
1603
|
+
: word.startsWith('--property=') ? word.slice('--property='.length) : null;
|
|
1604
|
+
if (property !== null) {
|
|
1605
|
+
const user = /^User=(.+)$/i.exec(property);
|
|
1606
|
+
if (user && isRootUserValue(user[1]))
|
|
1607
|
+
return 'systemd-run';
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
return null;
|
|
1611
|
+
}
|
|
1612
|
+
if (head === 'nsenter') {
|
|
1613
|
+
for (let j = 0; j < args.length; j++) {
|
|
1614
|
+
const word = args[j];
|
|
1615
|
+
if ((word === '-t' || word === '--target') && args[j + 1] !== undefined)
|
|
1616
|
+
return 'nsenter';
|
|
1617
|
+
// getopt allows the pid attached to the short flag: `-t1`.
|
|
1618
|
+
if (/^-t\d/.test(word) || (word.startsWith('--target=') && word.length > '--target='.length))
|
|
1619
|
+
return 'nsenter';
|
|
1620
|
+
}
|
|
1621
|
+
return null;
|
|
1622
|
+
}
|
|
1623
|
+
return null;
|
|
1624
|
+
}
|
|
1625
|
+
function brokeredElevationOf(command, dialect = 'posix') {
|
|
1626
|
+
for (const words of tokenizeSegments(command, dialect)) {
|
|
1627
|
+
const found = brokeredElevation(words);
|
|
1628
|
+
if (found !== null)
|
|
1629
|
+
return found;
|
|
1630
|
+
}
|
|
1631
|
+
return null;
|
|
1632
|
+
}
|
|
1421
1633
|
/**
|
|
1422
1634
|
* Does this command elevate anywhere a shell would act on it?
|
|
1423
1635
|
*
|
|
@@ -2294,6 +2506,30 @@ function hasUnreadableProgram(command, dialect = 'posix') {
|
|
|
2294
2506
|
const { words, sep } = segments[i];
|
|
2295
2507
|
if (sep === '|' && readsProgramFromStdin(words))
|
|
2296
2508
|
return true;
|
|
2509
|
+
// Carriers whose program is not on this command line at all, or is
|
|
2510
|
+
// written for a shell on another host (GHSA-cm4c-4q74-mgmj). All three
|
|
2511
|
+
// are "we cannot tell", the `awk -f progfile` answer, so `destructive`:
|
|
2512
|
+
// - `make`/`gmake` run recipes from a file this classifier never sees;
|
|
2513
|
+
// - `ssh` hands its operands to a login shell elsewhere — any non-flag
|
|
2514
|
+
// word means execution this text cannot read. A separated flag value
|
|
2515
|
+
// (`ssh -p 2222` with no destination) counts too, which only moves an
|
|
2516
|
+
// unrunnable fragment to `destructive`: every runnable ssh carries a
|
|
2517
|
+
// destination that counts anyway;
|
|
2518
|
+
// - `sqlite3` with a `.shell`/`.system` dot-command and `tar` with a
|
|
2519
|
+
// checkpoint `exec=` action spawn a shell for text the helpers emit
|
|
2520
|
+
// into `nestedCommands`, which is what puts an inner elevation above
|
|
2521
|
+
// `destructive`.
|
|
2522
|
+
const cmdIdx = effectiveCommandIndex(words);
|
|
2523
|
+
if (cmdIdx !== -1) {
|
|
2524
|
+
const head = foldCommandWord(stripPath(unquote(words[cmdIdx])));
|
|
2525
|
+
const operands = words.slice(cmdIdx + 1);
|
|
2526
|
+
if (RECIPE_RUNNERS.has(head))
|
|
2527
|
+
return true;
|
|
2528
|
+
if (head === 'ssh' && operands.some((w) => !w.startsWith('-')))
|
|
2529
|
+
return true;
|
|
2530
|
+
}
|
|
2531
|
+
if (sqliteShellPrograms(words).length > 0 || tarCheckpointExecPrograms(words).length > 0)
|
|
2532
|
+
return true;
|
|
2297
2533
|
if (!operandsAreData(words)) {
|
|
2298
2534
|
for (let j = 0; j < words.length; j++) {
|
|
2299
2535
|
const spec = resolveInterpreter(stripPath(unquote(words[j])));
|
|
@@ -2454,6 +2690,14 @@ function classifyOuter(trimmed, dialect) {
|
|
|
2454
2690
|
if (elevated !== null) {
|
|
2455
2691
|
return { binary: elevated, fullCommand, class: 'privileged' };
|
|
2456
2692
|
}
|
|
2693
|
+
// Checked beside the elevation it matches in authority but not in shape:
|
|
2694
|
+
// a broker needs no `sudo` in front of it, so `elevatedBinaryOf` never
|
|
2695
|
+
// fires, and the brokers each classified `safe` — the class no approval
|
|
2696
|
+
// gate ever sees (GHSA-cm4c-4q74-mgmj).
|
|
2697
|
+
const broker = brokeredElevationOf(trimmed, dialect);
|
|
2698
|
+
if (broker !== null) {
|
|
2699
|
+
return { binary: broker, fullCommand, class: 'privileged' };
|
|
2700
|
+
}
|
|
2457
2701
|
if (hasUnreadableProgram(trimmed, dialect) || hasDangerousAwk(trimmed, dialect)
|
|
2458
2702
|
|| isDestructive(trimmed, dialect) || hasDisqualifyingArgs(trimmed, dialect)) {
|
|
2459
2703
|
return { binary, fullCommand, class: 'destructive' };
|
|
@@ -59,7 +59,16 @@ export function registerCommandTools({ server, registry, policy }, pipeline) {
|
|
|
59
59
|
throw new PolicyRefusedError(`POLICY_DENIED: ${rawEval.reason || 'Command not allowed'}`, rawEval);
|
|
60
60
|
}
|
|
61
61
|
},
|
|
62
|
-
|
|
62
|
+
// `sudo -S` reads stdin only when it actually needs a password. Under
|
|
63
|
+
// NOPASSWD — or any policy that skips the prompt — the password line
|
|
64
|
+
// written for it stays in the pipe, and sudo exec's this `sh -c` with
|
|
65
|
+
// that same stdin, so a stdin-reading command could hand the password
|
|
66
|
+
// straight back (GHSA-9m75-x2ch-j4jr). Re-pointing fd 0 at /dev/null
|
|
67
|
+
// before the command runs means sudo still gets its password from the
|
|
68
|
+
// channel when it does prompt, and the command can never see it.
|
|
69
|
+
// stdin carries nothing else the command could want: the password
|
|
70
|
+
// line was the only thing ever written.
|
|
71
|
+
wrap: (cleanCmd) => `sudo -p "" -S sh -c ${shellSingleQuote(`exec 0</dev/null; ${cleanCmd}`)}`,
|
|
63
72
|
}, (rt) => {
|
|
64
73
|
const sudoPassword = rt.conn.getSudoPassword();
|
|
65
74
|
return execAndReport({ stdin: sudoPassword ? sudoPassword + '\n' : undefined })(rt);
|
|
@@ -7,7 +7,7 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
7
7
|
'read-session-output': 'Read recent output from a background session (e.g., tail -f logs).',
|
|
8
8
|
'read-command': 'Execute a READ-ONLY command from an allowlist (ls, cat, grep, find, stat, df, etc.). This tool does NOT modify the system. Prefer this tool for all read operations. Single-line only: a command containing a line break is refused, so upload a multi-line script with sftp-upload and run it by path.',
|
|
9
9
|
'run-command': 'Execute an arbitrary shell command on the remote server. May modify the system. Commands classified destructive or privileged go through the approval gate; approvalPolicy on the profile decides whether that is a prompt, an automatic allow, or a refusal. Single-line only: a command containing a line break is refused, so upload a multi-line script with sftp-upload and run it by path.',
|
|
10
|
-
'privileged-command': 'Execute a command with sudo elevation. Goes through the approval gate; approvalPolicy on the profile decides whether that is a prompt, an automatic allow, or a refusal. The sudo password is piped via stdin (never visible in process list). Single-line only: a command containing a line break is refused, so upload a multi-line script with sftp-upload and run it by path.',
|
|
10
|
+
'privileged-command': 'Execute a command with sudo elevation. Goes through the approval gate; approvalPolicy on the profile decides whether that is a prompt, an automatic allow, or a refusal. The sudo password is piped via stdin for the prompt only (never visible in process list); the command itself runs with stdin at /dev/null, so it can never read the password back. Single-line only: a command containing a line break is refused, so upload a multi-line script with sftp-upload and run it by path.',
|
|
11
11
|
'sftp-upload': 'Upload a file to the remote server via SFTP (secure file transfer, not shell-based). Replaces an existing file at that path unconditionally — there is no overwrite flag and no way to require a new destination.',
|
|
12
12
|
'sftp-download': 'Download a file from the remote server via SFTP.',
|
|
13
13
|
'sftp-list': 'List a remote directory over SFTP, with a bounded number of entries and a bounded response size. Read-only.',
|
package/build/tools/pipeline.js
CHANGED
|
@@ -219,7 +219,12 @@ export function createPipeline({ server, registry, policy, audit, approvalGrantT
|
|
|
219
219
|
onProgress: rt.onProgress,
|
|
220
220
|
abortSignal: rt.abortSignal,
|
|
221
221
|
});
|
|
222
|
-
|
|
222
|
+
// The sudo password is the one secret that can plausibly end up in this
|
|
223
|
+
// output (GHSA-9m75-x2ch-j4jr): it is written onto the very channel the
|
|
224
|
+
// command reads. Scrub it here whatever the exec path was.
|
|
225
|
+
const sudoPassword = rt.conn.getSudoPassword?.();
|
|
226
|
+
const literalSecrets = sudoPassword ? [sudoPassword] : undefined;
|
|
227
|
+
return { audited, output: commandOutput(audited, { literalSecrets }) };
|
|
223
228
|
};
|
|
224
229
|
}
|
|
225
230
|
return { runAudited, execAndReport, defaultProfileName, makeCtx, auditResult, auditFailure, resolveConn, quota, grants };
|
package/build/tools/results.js
CHANGED
|
@@ -10,14 +10,15 @@ export function textResult(text) {
|
|
|
10
10
|
* is reported as isError with the redacted stderr and the exit status, so a
|
|
11
11
|
* failed remote command cannot be mistaken for an empty success.
|
|
12
12
|
*/
|
|
13
|
-
export function commandOutput(result) {
|
|
13
|
+
export function commandOutput(result, opts = {}) {
|
|
14
14
|
// ssh2 reports exitCode null when the process died from a signal.
|
|
15
15
|
const failed = result.exitCode !== 0 || Boolean(result.signal);
|
|
16
16
|
// entropyScan matches the SFTP and session-output paths. Without it a plain
|
|
17
17
|
// `env` dump hands high-entropy secrets straight to the model, since the
|
|
18
18
|
// regex layer only knows a fixed set of token shapes.
|
|
19
|
-
const
|
|
20
|
-
const
|
|
19
|
+
const redactOpts = { entropyScan: true, literalSecrets: opts.literalSecrets };
|
|
20
|
+
const stdout = redactText(result.stdout, redactOpts);
|
|
21
|
+
const stderr = redactText(result.stderr, redactOpts);
|
|
21
22
|
const parts = [];
|
|
22
23
|
if (stdout)
|
|
23
24
|
parts.push(stdout);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ssh-mcp",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.20.0",
|
|
5
5
|
"description": "MCP server exposing policy-gated, audited SSH access for Linux and Windows hosts via the Model Context Protocol.",
|
|
6
6
|
"mcpName": "io.github.tufantunc/ssh-mcp",
|
|
7
7
|
"type": "module",
|