ssh-mcp 2.18.0 → 2.19.1
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/README.md +25 -5
- package/build/policy/classifier.js +377 -27
- package/build/policy/engine.js +18 -5
- package/build/policy/never-allowed-writes.js +51 -0
- package/build/tools/file-tools.js +1 -1
- package/build/tools/pipeline.js +6 -5
- package/build/tools/transfer-tools.js +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -593,11 +593,29 @@ read-only only with the options and operands its grammar lists; `--foo` is not a
|
|
|
593
593
|
there. ``), so the caller can see which spelling of the same command still works. See
|
|
594
594
|
[SECURITY.md](./SECURITY.md) for the residual risks this leaves open.
|
|
595
595
|
|
|
596
|
+
A reader also falls to `safe` when its command carries a shell metacharacter — `;` `&` `|`
|
|
597
|
+
`<` `>` `` ` `` `$` `(` `)` `{` `}`, a newline or a carriage return — quoted or not, because
|
|
598
|
+
the allowlist vouches for the binary, never for what a shell might do with the rest of the
|
|
599
|
+
line. The refusal names the first such character. For `sftp-list` and `sftp-download` it is
|
|
600
|
+
in the remote path, so `/srv/backup(old)` is refused for a `readOnly` profile with
|
|
601
|
+
`` The remote path contains `(`, which counts as a shell metacharacter here, so `sftp:list`
|
|
602
|
+
is read-only only for a path without one. ``
|
|
603
|
+
|
|
596
604
|
A separate **forbidden** list is never allowed, whatever the role or approval
|
|
597
605
|
policy: `rm -rf /`, `mkfs`, `dd of=/dev/`, `shutdown`, `curl|sh`, fork bombs,
|
|
598
|
-
writes
|
|
599
|
-
recursive `chmod 777 /` / `chown /`.
|
|
600
|
-
|
|
606
|
+
writes naming `/etc/cron`, `/etc/systemd` or any user's `authorized_keys`,
|
|
607
|
+
`iptables -F`, and recursive `chmod 777 /` / `chown /`. A write *names* one of
|
|
608
|
+
those paths when it is the remote path of an upload tool, the target of a shell
|
|
609
|
+
redirect, or a destination of `tee`, `cp`, `mv`, `install` or `dd of=` — in any
|
|
610
|
+
spelling the path readings above resolve. The two `/etc` families match by
|
|
611
|
+
prefix, so `/etc/crontab` is covered along with everything under `/etc/cron.d`,
|
|
612
|
+
and a sibling that merely starts like the directory (say `/etc/cronography`) is
|
|
613
|
+
refused too — the fail-closed direction. Writes spelled any other way (`sed -i`,
|
|
614
|
+
`rsync`, cmd.exe's `copy`, `move` and `robocopy`, a program handed to an
|
|
615
|
+
interpreter, a path the target computes) are not on this list; class and approval
|
|
616
|
+
govern them, and the operator denylist can close them. Add your own patterns via
|
|
617
|
+
the policy denylist; an invalid pattern fails at
|
|
618
|
+
startup rather than degrading silently.
|
|
601
619
|
|
|
602
620
|
### Approval Modes
|
|
603
621
|
|
|
@@ -693,8 +711,10 @@ The request shape is modeled on the AuthZEN Access Evaluation contract:
|
|
|
693
711
|
For the five SFTP tools, `resource` also carries three path keys: `remotePath`,
|
|
694
712
|
the path as given; and `remotePathNormalized` and `remotePathWindows`, the same
|
|
695
713
|
two readings the local denylist tests, so a rule can match a canonical spelling
|
|
696
|
-
without parsing `command` or reimplementing either reading in Rego.
|
|
697
|
-
|
|
714
|
+
without parsing `command` or reimplementing either reading in Rego. The two
|
|
715
|
+
upload tools add `remoteWrite: true`, the fact the local policy refuses the
|
|
716
|
+
never-allowed write paths on — a rule can gate writes without parsing `command`
|
|
717
|
+
either. Other tools omit all four, and so does an empty path. `remotePath` is **as given** —
|
|
698
718
|
`/root//.ssh/x` arrives spelled that way, so a `startswith` rule on a directory
|
|
699
719
|
misses it: match trailing segments, or match the normalized keys.
|
|
700
720
|
`remotePathWindows` keeps the case it was given; `lower()` both sides in the
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AWK_NAMES, readAwkInvocation } from './awk.js';
|
|
2
2
|
import { matchesGrammar } from './reader-grammar.js';
|
|
3
|
+
import { findNeverAllowedWriteTarget, neverAllowedWriteLabel } from './never-allowed-writes.js';
|
|
3
4
|
/**
|
|
4
5
|
* Anything through which the shell can start a second command.
|
|
5
6
|
*
|
|
@@ -588,7 +589,10 @@ const READ_ONLY_SYNTHETIC = new Set(['sftp:list', 'sftp:download']);
|
|
|
588
589
|
/**
|
|
589
590
|
* Commands that are never allowed, whatever the role or approval policy.
|
|
590
591
|
*
|
|
591
|
-
* This is the policy engine's denylist — the
|
|
592
|
+
* This is the policy engine's denylist — the regex half of it. The word-based
|
|
593
|
+
* rules live below in FORBIDDEN_RULES, and the write-path families in
|
|
594
|
+
* never-allowed-writes.ts, which both the rules and the engine's resource
|
|
595
|
+
* check read. The
|
|
592
596
|
* engine used to keep a parallel copy as regex *strings*, which had already
|
|
593
597
|
* drifted (it was missing the fork bomb and the recursive chown, and its `rm`
|
|
594
598
|
* pattern was narrower). Anything here is also destructive for classification
|
|
@@ -634,9 +638,6 @@ const FORBIDDEN_PATTERNS = [
|
|
|
634
638
|
// against the target host rather than against this server, which is not worth either
|
|
635
639
|
// trade — but it is a hole, not a closed door.
|
|
636
640
|
/:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/,
|
|
637
|
-
/>\s*\/etc\/cron/,
|
|
638
|
-
/>\s*\/etc\/systemd/,
|
|
639
|
-
/>\s*~\/.ssh\/authorized_keys/,
|
|
640
641
|
/\biptables\s+-F\b/,
|
|
641
642
|
/\bchmod\s+-R\s+777\s+\//,
|
|
642
643
|
];
|
|
@@ -1483,9 +1484,20 @@ function pipesDownloadIntoShell(command, dialect = 'posix') {
|
|
|
1483
1484
|
return false;
|
|
1484
1485
|
return heads.slice(firstDownload + 1).some((h) => h !== undefined && SHELLS.has(h));
|
|
1485
1486
|
}
|
|
1486
|
-
/**
|
|
1487
|
+
/**
|
|
1488
|
+
* `dd … of=/dev/sda` — writing an image straight onto a block device.
|
|
1489
|
+
*
|
|
1490
|
+
* Head via `effectiveCommandIndex`, not `parseSegments`: the privilege-prefix-only
|
|
1491
|
+
* machine missed a wrapped `env dd of=/dev/sda` (measured in review) while the
|
|
1492
|
+
* never-allowed write scan next door caught `env cp` — two dd readers two rules
|
|
1493
|
+
* apart must not answer through different head-extraction machines.
|
|
1494
|
+
*/
|
|
1487
1495
|
function writesToDevice(command, dialect = 'posix') {
|
|
1488
|
-
return
|
|
1496
|
+
return tokenizeSegments(command, dialect).some((words) => {
|
|
1497
|
+
const idx = effectiveCommandIndex(words);
|
|
1498
|
+
return idx !== -1 && stripPath(words[idx]) === 'dd'
|
|
1499
|
+
&& words.slice(idx + 1).some((a) => a.startsWith('of=/dev/'));
|
|
1500
|
+
});
|
|
1489
1501
|
}
|
|
1490
1502
|
/**
|
|
1491
1503
|
* `chown -R … /` — a recursive chown whose target is the filesystem root.
|
|
@@ -1501,6 +1513,309 @@ function chownsRoot(command, dialect = 'posix') {
|
|
|
1501
1513
|
return positional[positional.length - 1] === '/';
|
|
1502
1514
|
});
|
|
1503
1515
|
}
|
|
1516
|
+
/**
|
|
1517
|
+
* One dequoted word starting at `start`, as the redirect-target reader needs it.
|
|
1518
|
+
*
|
|
1519
|
+
* Stops at whitespace, at a character that begins another shell construct (`; | & < >
|
|
1520
|
+
* ( )`), or at the end. Quotes are dropped and a closing quote does not end the
|
|
1521
|
+
* word — the shell concatenates what follows it, so `> "/etc/cron.d"/x`,
|
|
1522
|
+
* `> ""~/x` and `> a"b"c` all read as one target (the review round measured the
|
|
1523
|
+
* `break`-on-close reading as a bypass: `> ""~/.ssh/authorized_keys` fell through).
|
|
1524
|
+
* The quote/escape state machine is the same one `tokenizeSegmentsDetailed` runs
|
|
1525
|
+
* (both keyed on DOUBLE_QUOTE_ESCAPABLE); a dialect fix must land in both loops.
|
|
1526
|
+
*/
|
|
1527
|
+
function readDequotedWord(command, start, dialect) {
|
|
1528
|
+
const n = command.length;
|
|
1529
|
+
if (start >= n || /[\s;&|<>()]/.test(command[start]))
|
|
1530
|
+
return '';
|
|
1531
|
+
let word = '';
|
|
1532
|
+
let quote = command[start] === '"' || command[start] === "'" ? command[start] : null;
|
|
1533
|
+
let escaped = false;
|
|
1534
|
+
for (let i = quote === null ? start : start + 1; i < n; i++) {
|
|
1535
|
+
const ch = command[i];
|
|
1536
|
+
if (escaped) {
|
|
1537
|
+
word += ch;
|
|
1538
|
+
escaped = false;
|
|
1539
|
+
continue;
|
|
1540
|
+
}
|
|
1541
|
+
if (dialect === 'posix' && ch === '\\') {
|
|
1542
|
+
if (quote === "'") {
|
|
1543
|
+
word += ch;
|
|
1544
|
+
continue;
|
|
1545
|
+
}
|
|
1546
|
+
if (quote === '"') {
|
|
1547
|
+
const next = command[i + 1];
|
|
1548
|
+
if (next === undefined || DOUBLE_QUOTE_ESCAPABLE.has(next)) {
|
|
1549
|
+
escaped = true;
|
|
1550
|
+
continue;
|
|
1551
|
+
}
|
|
1552
|
+
word += ch;
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
1555
|
+
escaped = true;
|
|
1556
|
+
continue;
|
|
1557
|
+
}
|
|
1558
|
+
if (quote) {
|
|
1559
|
+
if (ch === quote) {
|
|
1560
|
+
quote = null;
|
|
1561
|
+
continue;
|
|
1562
|
+
}
|
|
1563
|
+
word += ch;
|
|
1564
|
+
continue;
|
|
1565
|
+
}
|
|
1566
|
+
if (ch === '"' || ch === "'") {
|
|
1567
|
+
quote = ch;
|
|
1568
|
+
continue;
|
|
1569
|
+
}
|
|
1570
|
+
if (/[\s;&|<>()]/.test(ch))
|
|
1571
|
+
break;
|
|
1572
|
+
word += ch;
|
|
1573
|
+
}
|
|
1574
|
+
return word;
|
|
1575
|
+
}
|
|
1576
|
+
/**
|
|
1577
|
+
* Every shell-redirect target in `command`, dequoted the way the shell will pass it.
|
|
1578
|
+
*
|
|
1579
|
+
* A redirect is an unquoted `>` — alone (`> f`), attached to the word it ends
|
|
1580
|
+
* (`echo x>f`, which bash also reads as a redirect), doubled (`>>`), with an fd
|
|
1581
|
+
* prefix (`2>`, `2>>`), with the both-streams spellings (`&>`, `&>>`, `>&`) or
|
|
1582
|
+
* with the noclobber-override marker (`>|`). A `>` inside quotes is data
|
|
1583
|
+
* (`echo "> f"` prints a string) and yields nothing, which is what separates this
|
|
1584
|
+
* scan from the raw regexes it replaces: those fired on the quoted text too, so a
|
|
1585
|
+
* commit message quoting a redirect was a refusal nobody could switch off
|
|
1586
|
+
* (measured on main).
|
|
1587
|
+
*
|
|
1588
|
+
* The target is the next word after the operator, quoted or attached. An operator
|
|
1589
|
+
* with nothing readable after it is a syntax error the remote shell would report,
|
|
1590
|
+
* and yields nothing. `>(...)` process substitution yields nothing here too — its
|
|
1591
|
+
* inner command is a carrier `nestedCommands` already reads.
|
|
1592
|
+
*/
|
|
1593
|
+
function redirectTargets(command, dialect) {
|
|
1594
|
+
const targets = [];
|
|
1595
|
+
const n = command.length;
|
|
1596
|
+
let quote = null;
|
|
1597
|
+
let escaped = false;
|
|
1598
|
+
for (let i = 0; i < n; i++) {
|
|
1599
|
+
const ch = command[i];
|
|
1600
|
+
if (escaped) {
|
|
1601
|
+
escaped = false;
|
|
1602
|
+
continue;
|
|
1603
|
+
}
|
|
1604
|
+
if (dialect === 'posix' && ch === '\\' && quote !== "'") {
|
|
1605
|
+
escaped = true;
|
|
1606
|
+
continue;
|
|
1607
|
+
}
|
|
1608
|
+
if (quote) {
|
|
1609
|
+
if (ch === quote)
|
|
1610
|
+
quote = null;
|
|
1611
|
+
continue;
|
|
1612
|
+
}
|
|
1613
|
+
if (ch === '"' || ch === "'") {
|
|
1614
|
+
quote = ch;
|
|
1615
|
+
continue;
|
|
1616
|
+
}
|
|
1617
|
+
if (ch !== '>' && !(ch === '&' && command[i + 1] === '>'))
|
|
1618
|
+
continue;
|
|
1619
|
+
let j = ch === '&' ? i + 2 : i + 1;
|
|
1620
|
+
if (command[j] === '>')
|
|
1621
|
+
j++; // >>
|
|
1622
|
+
if (command[j] === '&')
|
|
1623
|
+
j++; // >& fd duplication
|
|
1624
|
+
if (command[j] === '|')
|
|
1625
|
+
j++; // >| noclobber override
|
|
1626
|
+
while (j < n && /\s/.test(command[j]))
|
|
1627
|
+
j++;
|
|
1628
|
+
const word = readDequotedWord(command, j, dialect);
|
|
1629
|
+
if (word !== '')
|
|
1630
|
+
targets.push(word);
|
|
1631
|
+
i = j - 1;
|
|
1632
|
+
}
|
|
1633
|
+
return targets;
|
|
1634
|
+
}
|
|
1635
|
+
/**
|
|
1636
|
+
* xargs's own value-taking flags — one tool's option set, enumerable the way
|
|
1637
|
+
* `PREFIX_VALUE_FLAGS` holds sudo's. `effectiveCommandIndex` steps a wrapper's
|
|
1638
|
+
* flags with *sudo's* set, so `xargs -I {}` leaves `{}` looking like the command
|
|
1639
|
+
* and the verb xargs really runs hides behind it (measured in review:
|
|
1640
|
+
* `xargs -I {} cp {} /root/.ssh/authorized_keys` passed).
|
|
1641
|
+
*/
|
|
1642
|
+
const XARGS_VALUE_FLAGS = new Set(['-I', '-J', '-d', '-E', '-n', '-s', '-P', '-R', '-L', '-S']);
|
|
1643
|
+
/**
|
|
1644
|
+
* The command index for a segment whose wrapper may be `xargs`, which the plain
|
|
1645
|
+
* `effectiveCommandIndex` cannot step past. Same walk, one addition; runs
|
|
1646
|
+
* alongside the plain index so nothing else changes.
|
|
1647
|
+
*/
|
|
1648
|
+
function indexPastXargsFlags(words) {
|
|
1649
|
+
let i = 0;
|
|
1650
|
+
while (i < words.length) {
|
|
1651
|
+
const raw = words[i];
|
|
1652
|
+
const word = stripPath(unquote(raw));
|
|
1653
|
+
if (word === 'xargs') {
|
|
1654
|
+
i++;
|
|
1655
|
+
while (i < words.length && words[i].startsWith('-')) {
|
|
1656
|
+
const consumesValue = XARGS_VALUE_FLAGS.has(words[i]);
|
|
1657
|
+
i++;
|
|
1658
|
+
if (consumesValue)
|
|
1659
|
+
i++;
|
|
1660
|
+
}
|
|
1661
|
+
continue;
|
|
1662
|
+
}
|
|
1663
|
+
if (PRIVILEGE_PREFIXES.has(word) || EXEC_WRAPPERS.has(word)) {
|
|
1664
|
+
i++;
|
|
1665
|
+
continue;
|
|
1666
|
+
}
|
|
1667
|
+
if (ASSIGNMENT.test(raw) || raw.startsWith('-') || BARE_NUMERIC.test(raw)) {
|
|
1668
|
+
i++;
|
|
1669
|
+
continue;
|
|
1670
|
+
}
|
|
1671
|
+
return i;
|
|
1672
|
+
}
|
|
1673
|
+
return -1;
|
|
1674
|
+
}
|
|
1675
|
+
/**
|
|
1676
|
+
* The positional operands of the copy family, with every option spelling that
|
|
1677
|
+
* carries a value stepped over.
|
|
1678
|
+
*
|
|
1679
|
+
* GNU getopt permutes options anywhere in the argv — `install k
|
|
1680
|
+
* /root/.ssh/authorized_keys -m 600` is ordinary usage — so a value-taking flag
|
|
1681
|
+
* trailing the destination must not have its value read as the last positional
|
|
1682
|
+
* (measured in review: it displaced the destination and the write was missed).
|
|
1683
|
+
* `-t`/`--target-directory` in all three spellings (`-t DIR`, `-tDIR`,
|
|
1684
|
+
* `--target-directory=DIR`, `--target-directory DIR`) is stepped here too; the
|
|
1685
|
+
* caller reads its value as the destination itself.
|
|
1686
|
+
*/
|
|
1687
|
+
function copyPositionals(args) {
|
|
1688
|
+
const positional = [];
|
|
1689
|
+
for (let i = 0; i < args.length; i++) {
|
|
1690
|
+
const arg = args[i];
|
|
1691
|
+
if (arg === '-t' || arg === '--target-directory') {
|
|
1692
|
+
i++;
|
|
1693
|
+
continue;
|
|
1694
|
+
}
|
|
1695
|
+
if (/^-t.+/.test(arg) || /^--target-directory=/.test(arg))
|
|
1696
|
+
continue;
|
|
1697
|
+
// An attached short value (`-m600`) needs no line of its own: the generic
|
|
1698
|
+
// `-` skip below already keeps it out of the positionals.
|
|
1699
|
+
if (arg === '-m' || arg === '-o' || arg === '-g' || arg === '-S' || arg === '-Z') {
|
|
1700
|
+
i++;
|
|
1701
|
+
continue;
|
|
1702
|
+
}
|
|
1703
|
+
if (/^--(mode|owner|group|suffix|context|sparse)=/.test(arg))
|
|
1704
|
+
continue;
|
|
1705
|
+
if (arg === '--mode' || arg === '--owner' || arg === '--group' || arg === '--suffix'
|
|
1706
|
+
|| arg === '--context' || arg === '--sparse') {
|
|
1707
|
+
i++;
|
|
1708
|
+
continue;
|
|
1709
|
+
}
|
|
1710
|
+
if (arg.startsWith('-'))
|
|
1711
|
+
continue;
|
|
1712
|
+
positional.push(arg);
|
|
1713
|
+
}
|
|
1714
|
+
return positional;
|
|
1715
|
+
}
|
|
1716
|
+
/**
|
|
1717
|
+
* The destination operands of the verbs whose argv names the file they write.
|
|
1718
|
+
*
|
|
1719
|
+
* A closed list, on purpose. `tee` writes every non-flag operand; `cp`, `mv` and
|
|
1720
|
+
* `install` write their destination operand — the value of `-t`/
|
|
1721
|
+
* `--target-directory` in any of its spellings when present, else the last
|
|
1722
|
+
* positional — and, because that operand may name a directory the sources are
|
|
1723
|
+
* copied *into*, the same joined with each source's basename, so `cp
|
|
1724
|
+
* authorized_keys ~/.ssh/` is caught rather than only `cp k
|
|
1725
|
+
* ~/.ssh/authorized_keys`. `install -d` creates every operand. `dd of=` writes
|
|
1726
|
+
* the value. Every other verb is a documented residual: a guess about an
|
|
1727
|
+
* unrecognised binary's operands must not reach this list (the
|
|
1728
|
+
* `speculativeOperands: false` ruling), and #230's decision 1 already ruled that
|
|
1729
|
+
* extracting paths from shell text can never be complete — so the README lists
|
|
1730
|
+
* these forms and names the rest as governed by class and approval instead.
|
|
1731
|
+
*
|
|
1732
|
+
* `env cp …`, `sudo cp …`, `timeout 5 cp …` and `xargs -I {} cp …` reach the
|
|
1733
|
+
* verb through `effectiveCommandIndex` and its xargs-stepping sibling.
|
|
1734
|
+
*/
|
|
1735
|
+
function writeVerbTargets(command, dialect) {
|
|
1736
|
+
const targets = [];
|
|
1737
|
+
for (const words of tokenizeSegments(command, dialect)) {
|
|
1738
|
+
const heads = [effectiveCommandIndex(words), indexPastXargsFlags(words)]
|
|
1739
|
+
.filter((i, idx, all) => i !== -1 && all.indexOf(i) === idx);
|
|
1740
|
+
for (const idx of heads) {
|
|
1741
|
+
const head = stripPath(words[idx]);
|
|
1742
|
+
const args = words.slice(idx + 1);
|
|
1743
|
+
if (head === 'tee') {
|
|
1744
|
+
targets.push(...args.filter((a) => !a.startsWith('-')));
|
|
1745
|
+
continue;
|
|
1746
|
+
}
|
|
1747
|
+
if (head === 'dd') {
|
|
1748
|
+
for (const arg of args) {
|
|
1749
|
+
if (arg.startsWith('of='))
|
|
1750
|
+
targets.push(arg.slice(3));
|
|
1751
|
+
}
|
|
1752
|
+
continue;
|
|
1753
|
+
}
|
|
1754
|
+
if (head === 'cp' || head === 'mv' || head === 'install') {
|
|
1755
|
+
const positional = copyPositionals(args);
|
|
1756
|
+
if (head === 'install' && args.some((a) => a === '-d' || a === '--directory')) {
|
|
1757
|
+
targets.push(...positional);
|
|
1758
|
+
continue;
|
|
1759
|
+
}
|
|
1760
|
+
const tIndex = args.findIndex((a) => a === '-t' || a === '--target-directory'
|
|
1761
|
+
|| /^-t.+/.test(a) || a.startsWith('--target-directory='));
|
|
1762
|
+
let dest = null;
|
|
1763
|
+
let sources = positional;
|
|
1764
|
+
if (tIndex !== -1) {
|
|
1765
|
+
const flag = args[tIndex];
|
|
1766
|
+
if (flag === '-t' || flag === '--target-directory') {
|
|
1767
|
+
dest = args[tIndex + 1] || null;
|
|
1768
|
+
}
|
|
1769
|
+
else {
|
|
1770
|
+
const eq = flag.indexOf('=');
|
|
1771
|
+
dest = eq === -1 ? flag.slice(2) : flag.slice(eq + 1);
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
else if (positional.length >= 2) {
|
|
1775
|
+
dest = positional[positional.length - 1];
|
|
1776
|
+
sources = positional.slice(0, -1);
|
|
1777
|
+
}
|
|
1778
|
+
if (dest === null || dest === '')
|
|
1779
|
+
continue;
|
|
1780
|
+
targets.push(dest);
|
|
1781
|
+
for (const src of sources) {
|
|
1782
|
+
const base = stripPath(src);
|
|
1783
|
+
if (base !== '')
|
|
1784
|
+
targets.push(`${dest}/${base}`);
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
return targets;
|
|
1790
|
+
}
|
|
1791
|
+
/**
|
|
1792
|
+
* The never-allowed family cache — same shape and reason as `normalizedCache`:
|
|
1793
|
+
* three ForbiddenRule entries ask the same (command, dialect) question in a row,
|
|
1794
|
+
* and each ask re-ran both scans over the whole command. Keyed with the dialect
|
|
1795
|
+
* because both readings of one command are asked in the same row.
|
|
1796
|
+
*/
|
|
1797
|
+
const neverAllowedWriteCache = new Map();
|
|
1798
|
+
/**
|
|
1799
|
+
* Which never-allowed family this command's certain write forms name, or null.
|
|
1800
|
+
* Both dialects are tried by the caller (findForbiddenMatchInDialect), and nested
|
|
1801
|
+
* commands reach this through the same recursion every other rule uses.
|
|
1802
|
+
*/
|
|
1803
|
+
function findNeverAllowedWriteFamily(command, dialect) {
|
|
1804
|
+
const key = `${dialect}\u0000${command}`;
|
|
1805
|
+
if (neverAllowedWriteCache.has(key))
|
|
1806
|
+
return neverAllowedWriteCache.get(key);
|
|
1807
|
+
let family = null;
|
|
1808
|
+
for (const candidate of [...redirectTargets(command, dialect), ...writeVerbTargets(command, dialect)]) {
|
|
1809
|
+
family = findNeverAllowedWriteTarget(candidate);
|
|
1810
|
+
if (family !== null)
|
|
1811
|
+
break;
|
|
1812
|
+
}
|
|
1813
|
+
neverAllowedWriteCache.set(key, family);
|
|
1814
|
+
if (neverAllowedWriteCache.size > 8) {
|
|
1815
|
+
neverAllowedWriteCache.delete(neverAllowedWriteCache.keys().next().value);
|
|
1816
|
+
}
|
|
1817
|
+
return family;
|
|
1818
|
+
}
|
|
1504
1819
|
const FORBIDDEN_RULES = [
|
|
1505
1820
|
...FORBIDDEN_PATTERNS.map((re) => ({
|
|
1506
1821
|
label: String(re),
|
|
@@ -1516,6 +1831,10 @@ const FORBIDDEN_RULES = [
|
|
|
1516
1831
|
},
|
|
1517
1832
|
{ label: 'dd writing to a block device (of=/dev/…)', test: (c, dialect) => writesToDevice(c, dialect) },
|
|
1518
1833
|
{ label: 'a recursive chown of the filesystem root', test: (c, dialect) => chownsRoot(c, dialect) },
|
|
1834
|
+
...['/etc/cron', '/etc/systemd', 'authorized_keys'].map((family) => ({
|
|
1835
|
+
label: neverAllowedWriteLabel(family),
|
|
1836
|
+
test: (command, dialect) => findNeverAllowedWriteFamily(command, dialect) === family,
|
|
1837
|
+
})),
|
|
1519
1838
|
];
|
|
1520
1839
|
/**
|
|
1521
1840
|
* Which never-allowed rule this command trips, or null. The single entry point:
|
|
@@ -2109,9 +2428,10 @@ function classifyCommandInDialect(command, depth, dialect) {
|
|
|
2109
2428
|
if (CLASS_RANK[parsed.class] > CLASS_RANK[highest.class])
|
|
2110
2429
|
highest = parsed;
|
|
2111
2430
|
}
|
|
2112
|
-
// `readOnlyRejection` only ever describes the outer command's
|
|
2113
|
-
//
|
|
2114
|
-
//
|
|
2431
|
+
// `readOnlyRejection` only ever describes why the outer command's reader was
|
|
2432
|
+
// refused read-only — a grammar rejection or a shell metacharacter, see
|
|
2433
|
+
// `ReadOnlyRejection` — and only means anything when that is also what decided
|
|
2434
|
+
// the final class. `highest === outer` (reference equality) is exactly that
|
|
2115
2435
|
// condition: the synthetic floor and the nested-command loop above only
|
|
2116
2436
|
// ever replace `highest` with something of strictly higher rank than
|
|
2117
2437
|
// `outer`'s, so if either one fired, `highest` is no longer `outer` and the
|
|
@@ -2153,8 +2473,20 @@ function classifyOuter(trimmed, dialect) {
|
|
|
2153
2473
|
const twoWordPrefix = words.slice(0, 2).join(' ');
|
|
2154
2474
|
if (READ_ONLY_ALLOWLIST.has(binary) || READ_ONLY_ALLOWLIST.has(twoWordPrefix)
|
|
2155
2475
|
|| READ_ONLY_SYNTHETIC.has(binary)) {
|
|
2156
|
-
|
|
2157
|
-
|
|
2476
|
+
// Whichever key matched decides both which entry answers and how many
|
|
2477
|
+
// leading words are the command name rather than an argument: a two-word
|
|
2478
|
+
// entry's grammar reads words[2..], a one-word entry's reads words[1..]. An
|
|
2479
|
+
// SFTP verb's two-word prefix is never a key, so it is named by itself.
|
|
2480
|
+
const matchedTwoWord = READ_ONLY_ALLOWLIST.has(twoWordPrefix);
|
|
2481
|
+
const readerName = matchedTwoWord ? twoWordPrefix : binary;
|
|
2482
|
+
const metacharacter = SHELL_CONTROL_CHARS.exec(trimmed);
|
|
2483
|
+
if (metacharacter) {
|
|
2484
|
+
return {
|
|
2485
|
+
binary,
|
|
2486
|
+
fullCommand,
|
|
2487
|
+
class: 'safe',
|
|
2488
|
+
readOnlyRejection: { binary: readerName, word: metacharacter[0], cause: 'shell-metacharacter' },
|
|
2489
|
+
};
|
|
2158
2490
|
}
|
|
2159
2491
|
// The SFTP verbs are synthesised by the tool layer, never typed by a caller,
|
|
2160
2492
|
// so there is no argv for a grammar to read — READ_ONLY_SYNTHETIC stays
|
|
@@ -2162,11 +2494,6 @@ function classifyOuter(trimmed, dialect) {
|
|
|
2162
2494
|
if (READ_ONLY_SYNTHETIC.has(binary)) {
|
|
2163
2495
|
return { binary, fullCommand, class: 'read-only' };
|
|
2164
2496
|
}
|
|
2165
|
-
// Whichever key matched decides both which entry answers and how many
|
|
2166
|
-
// leading words are the command name rather than an argument: a two-word
|
|
2167
|
-
// entry's grammar reads words[2..], a one-word entry's reads words[1..].
|
|
2168
|
-
const matchedTwoWord = READ_ONLY_ALLOWLIST.has(twoWordPrefix);
|
|
2169
|
-
const readerName = matchedTwoWord ? twoWordPrefix : binary;
|
|
2170
2497
|
const entry = READERS[readerName];
|
|
2171
2498
|
const argWords = words.slice(matchedTwoWord ? 2 : 1);
|
|
2172
2499
|
// An unquoted glob word is not a word this grammar can vouch for: the
|
|
@@ -2182,7 +2509,7 @@ function classifyOuter(trimmed, dialect) {
|
|
|
2182
2509
|
binary,
|
|
2183
2510
|
fullCommand,
|
|
2184
2511
|
class: 'safe',
|
|
2185
|
-
readOnlyRejection: { binary: readerName, word: globWord },
|
|
2512
|
+
readOnlyRejection: { binary: readerName, word: globWord, cause: 'grammar' },
|
|
2186
2513
|
};
|
|
2187
2514
|
}
|
|
2188
2515
|
}
|
|
@@ -2192,7 +2519,7 @@ function classifyOuter(trimmed, dialect) {
|
|
|
2192
2519
|
binary,
|
|
2193
2520
|
fullCommand,
|
|
2194
2521
|
class: 'safe',
|
|
2195
|
-
readOnlyRejection: { binary: readerName, word: match.word },
|
|
2522
|
+
readOnlyRejection: { binary: readerName, word: match.word, cause: 'grammar' },
|
|
2196
2523
|
};
|
|
2197
2524
|
}
|
|
2198
2525
|
return { binary, fullCommand, class: 'read-only' };
|
|
@@ -2200,17 +2527,40 @@ function classifyOuter(trimmed, dialect) {
|
|
|
2200
2527
|
return { binary, fullCommand, class: 'safe' };
|
|
2201
2528
|
}
|
|
2202
2529
|
/**
|
|
2203
|
-
* The sentence a refusal appends when it carries a `readOnlyRejection`,
|
|
2204
|
-
*
|
|
2205
|
-
*
|
|
2206
|
-
*
|
|
2207
|
-
*
|
|
2208
|
-
*
|
|
2209
|
-
*
|
|
2210
|
-
*
|
|
2211
|
-
*
|
|
2530
|
+
* The sentence a refusal appends when it carries a `readOnlyRejection`, in one of
|
|
2531
|
+
* two shapes chosen by its `cause`. Both name the reader that would have qualified
|
|
2532
|
+
* and what cost it the class.
|
|
2533
|
+
*
|
|
2534
|
+
* - `grammar`: names the argv word, as "is not accepted there" rather than "is not
|
|
2535
|
+
* one of them", because most rejections are operand rules (`hostname NAME`,
|
|
2536
|
+
* `sort /data`) or a listed value flag missing its value, where "is not one of
|
|
2537
|
+
* them" would be false — it may be a perfectly good flag name, just not one bare
|
|
2538
|
+
* or in this position.
|
|
2539
|
+
* - `shell-metacharacter`: names the character, and for the SFTP verbs says it is
|
|
2540
|
+
* in the remote path (#224).
|
|
2541
|
+
*
|
|
2542
|
+
* One function so the engine's role-binding denial and `read-command`'s
|
|
2543
|
+
* `enforceClass` refusal never drift into two spellings of the same sentence.
|
|
2212
2544
|
*/
|
|
2213
2545
|
function formatReadOnlyRejection(rejection) {
|
|
2546
|
+
if (rejection.cause === 'shell-metacharacter') {
|
|
2547
|
+
// `\n` and `\r` are in SHELL_CONTROL_CHARS, and quoted raw they would break the
|
|
2548
|
+
// sentence across lines or render as nothing. A backtick is in it too, and inside
|
|
2549
|
+
// single backticks it would print as three in a row; double backticks with
|
|
2550
|
+
// padding is Markdown's own way to quote one.
|
|
2551
|
+
const char = rejection.word.replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
|
2552
|
+
const quoted = char === '`' ? '`` ` ``' : `\`${char}\``;
|
|
2553
|
+
// The SFTP verbs' operand is a path the tool layer built the command from, so the
|
|
2554
|
+
// path is what the caller can change — not "the command", which they never typed.
|
|
2555
|
+
// The verb is named as the audit record spells it (`sftp:list`), not as the tool
|
|
2556
|
+
// (`sftp-list`), so the refusal and the record it is filed under read the same.
|
|
2557
|
+
if (READ_ONLY_SYNTHETIC.has(rejection.binary)) {
|
|
2558
|
+
return `The remote path contains ${quoted}, which counts as a shell metacharacter here, ` +
|
|
2559
|
+
`so \`${rejection.binary}\` is read-only only for a path without one.`;
|
|
2560
|
+
}
|
|
2561
|
+
return `\`${rejection.binary}\` is read-only only when the command contains no shell metacharacter; ` +
|
|
2562
|
+
`${quoted} is one.`;
|
|
2563
|
+
}
|
|
2214
2564
|
// The rejected word can be the empty string — a quoted empty operand
|
|
2215
2565
|
// (`hostname ""`) — and two bare backticks would render as a blank where
|
|
2216
2566
|
// the word that cost the class should be. `""` is the shell's own spelling
|
package/build/policy/engine.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { classifyCommand, findForbiddenMatch, formatReadOnlyRejection } from './classifier.js';
|
|
2
2
|
import { normalizeRemotePath, normalizeRemotePathForWindows } from './remote-path.js';
|
|
3
|
+
import { findNeverAllowedWriteTarget, neverAllowedWriteLabel } from './never-allowed-writes.js';
|
|
3
4
|
import { OperatorError } from '../errors.js';
|
|
5
|
+
/** The operator-facing promise every built-in never-allowed refusal ends with. */
|
|
6
|
+
const NEVER_ALLOWED_TRAILER = 'This list cannot be switched off — the [policy].denylist key adds patterns, it does not remove these.';
|
|
4
7
|
/**
|
|
5
8
|
* How long to wait for the OPA sidecar before treating it as unavailable.
|
|
6
9
|
*
|
|
@@ -316,6 +319,7 @@ export class PolicyEngine {
|
|
|
316
319
|
binary: parsed.binary,
|
|
317
320
|
host: profile.host,
|
|
318
321
|
remotePath,
|
|
322
|
+
remoteWrite: resource.remoteWrite || undefined,
|
|
319
323
|
remotePathNormalized: remotePath === undefined ? undefined : normalizeRemotePath(remotePath),
|
|
320
324
|
remotePathWindows: remotePath === undefined ? undefined : normalizeRemotePathForWindows(remotePath),
|
|
321
325
|
},
|
|
@@ -449,9 +453,9 @@ export class PolicyEngine {
|
|
|
449
453
|
}
|
|
450
454
|
}
|
|
451
455
|
// A `safe` command refused here is often not a hard "no" — it is one
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
// have worked.
|
|
456
|
+
// spelling away from `read-only` (an argv word the grammar refused, or a
|
|
457
|
+
// shell metacharacter), and without this the reader concludes the binary is
|
|
458
|
+
// forbidden outright and never learns which spelling would have worked.
|
|
455
459
|
if (parsed.readOnlyRejection) {
|
|
456
460
|
reason += ` ${formatReadOnlyRejection(parsed.readOnlyRejection)}`;
|
|
457
461
|
}
|
|
@@ -505,8 +509,17 @@ export class PolicyEngine {
|
|
|
505
509
|
findDenyMatch(command, resource) {
|
|
506
510
|
const builtIn = findForbiddenMatch(command);
|
|
507
511
|
if (builtIn) {
|
|
508
|
-
return `Command matches a built-in never-allowed rule: ${builtIn}.
|
|
509
|
-
|
|
512
|
+
return `Command matches a built-in never-allowed rule: ${builtIn}. ${NEVER_ALLOWED_TRAILER}`;
|
|
513
|
+
}
|
|
514
|
+
// The built-in never-allowed write paths, tested against a remote path the tool
|
|
515
|
+
// declared it writes. Runs before the early return below because the defaults
|
|
516
|
+
// carry no operator denylist — this check is not the operator's to switch on.
|
|
517
|
+
if (resource.remoteWrite && resource.remotePath) {
|
|
518
|
+
const target = findNeverAllowedWriteTarget(resource.remotePath);
|
|
519
|
+
if (target !== null) {
|
|
520
|
+
return `Remote path ${quotePath(resource.remotePath)} matches a built-in never-allowed rule: ` +
|
|
521
|
+
`${neverAllowedWriteLabel(target)}. ${NEVER_ALLOWED_TRAILER}`;
|
|
522
|
+
}
|
|
510
523
|
}
|
|
511
524
|
// Nothing below can match: skip reading the path at all, which is the commonest
|
|
512
525
|
// config — the defaults carry no denylist.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { normalizeRemotePath, normalizeRemotePathForWindows } from './remote-path.js';
|
|
2
|
+
/**
|
|
3
|
+
* The `/etc` families are prefix matches with no boundary: `/etc/crontab` sits under
|
|
4
|
+
* `/etc/cron` the same way `/etc/cron.d/x` does, which is how the redirect regex this
|
|
5
|
+
* module replaces always read it. A sibling that merely starts like the directory
|
|
6
|
+
* (`/etc/cronography`) fires too — over-refusal in the fail-closed direction this
|
|
7
|
+
* engine takes everywhere else.
|
|
8
|
+
*/
|
|
9
|
+
const NEVER_DIRS = ['/etc/cron', '/etc/systemd'];
|
|
10
|
+
/** The keys family: any path whose last two segments are `.ssh/authorized_keys`. */
|
|
11
|
+
const KEYS_TAIL = '/.ssh/authorized_keys';
|
|
12
|
+
/**
|
|
13
|
+
* The refusal wording for a family, defined once because two check sites quote it:
|
|
14
|
+
* the classifier's ForbiddenRule label and the engine's built-in deny reason.
|
|
15
|
+
*/
|
|
16
|
+
export function neverAllowedWriteLabel(target) {
|
|
17
|
+
switch (target) {
|
|
18
|
+
case '/etc/cron': return 'writing to /etc/cron';
|
|
19
|
+
case '/etc/systemd': return 'writing to /etc/systemd';
|
|
20
|
+
case 'authorized_keys': return "writing to any user's authorized_keys";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function familyOf(path) {
|
|
24
|
+
for (const dir of NEVER_DIRS) {
|
|
25
|
+
if (path.startsWith(dir))
|
|
26
|
+
return dir;
|
|
27
|
+
}
|
|
28
|
+
if (path.endsWith(KEYS_TAIL) || path === KEYS_TAIL.slice(1))
|
|
29
|
+
return 'authorized_keys';
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Whether `path` names one of the never-allowed write targets, in any spelling the
|
|
34
|
+
* path readings resolve.
|
|
35
|
+
*
|
|
36
|
+
* The same three readings `[policy].denylist` sees (#230): the path as given; a
|
|
37
|
+
* lexically normalized reading (`//`, `/./`, `..`, trailing separators, `\` as a
|
|
38
|
+
* separator); and the Windows reading of it, case-insensitively — so `Authorized_Keys.`,
|
|
39
|
+
* `authorized_keys::$DATA` and backslash spellings reach the same refusal. On a POSIX
|
|
40
|
+
* target some of those are different files; refusing them is the safe direction, the
|
|
41
|
+
* same trade the operator denylist already makes.
|
|
42
|
+
*
|
|
43
|
+
* What this does not do is resolve anything on the target: a relative path stays
|
|
44
|
+
* relative, symlinks are not followed, and an 8.3 short name is not expanded (#230's
|
|
45
|
+
* documented residuals, inherited here).
|
|
46
|
+
*/
|
|
47
|
+
export function findNeverAllowedWriteTarget(path) {
|
|
48
|
+
return familyOf(path)
|
|
49
|
+
?? familyOf(normalizeRemotePath(path))
|
|
50
|
+
?? familyOf(normalizeRemotePathForWindows(path).toLowerCase());
|
|
51
|
+
}
|
|
@@ -53,7 +53,7 @@ export function registerFileTools({ server }, pipeline) {
|
|
|
53
53
|
profile,
|
|
54
54
|
extra,
|
|
55
55
|
synthetic: true,
|
|
56
|
-
resource: { remotePath },
|
|
56
|
+
resource: { remotePath, remoteWrite: true },
|
|
57
57
|
preCheck: () => { sanitizeRemotePath(remotePath); },
|
|
58
58
|
}, async (rt) => {
|
|
59
59
|
await new SftpClient(rt.conn).upload({ remotePath, content });
|
package/build/tools/pipeline.js
CHANGED
|
@@ -170,11 +170,12 @@ export function createPipeline({ server, registry, policy, audit, approvalGrantT
|
|
|
170
170
|
const { conn, evaluation, approver } = await checkPolicyAndApprove(effective, profileName, opts.toolName, opts.resource);
|
|
171
171
|
state.evaluation = evaluation;
|
|
172
172
|
if (opts.enforceClass && evaluation.commandClass !== opts.enforceClass) {
|
|
173
|
-
// A command that fell to `safe` because a reader
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
173
|
+
// A command that fell to `safe` because a reader was refused read-only —
|
|
174
|
+
// its grammar rejected an argv word, or the command carries a shell
|
|
175
|
+
// metacharacter — is one spelling away from the class this tool demands.
|
|
176
|
+
// Say which word or character, or the caller only learns that the tool
|
|
177
|
+
// refused it, not why the reader that ran the exact same binary would
|
|
178
|
+
// not have.
|
|
178
179
|
const rejectionSuffix = evaluation.readOnlyRejection
|
|
179
180
|
? ` ${formatReadOnlyRejection(evaluation.readOnlyRejection)}`
|
|
180
181
|
: '';
|
|
@@ -256,7 +256,7 @@ export function registerTransferTools({ server, localPath }, pipeline) {
|
|
|
256
256
|
profile,
|
|
257
257
|
extra,
|
|
258
258
|
synthetic: true,
|
|
259
|
-
resource: { remotePath },
|
|
259
|
+
resource: { remotePath, remoteWrite: true },
|
|
260
260
|
preCheck: () => { sanitizeRemotePath(remotePath); checkMode(mode); },
|
|
261
261
|
}, async (rt) => {
|
|
262
262
|
const maxBytes = rt.conn.profile.transferMaxBytes;
|
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.19.1",
|
|
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",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"typecheck": "tsc --noEmit -p tsconfig.test.json",
|
|
15
15
|
"inspect": "npx @modelcontextprotocol/inspector node build/index.js",
|
|
16
16
|
"changeset": "changeset",
|
|
17
|
-
"version": "changeset version && node scripts/sync-server-json.mjs",
|
|
17
|
+
"version": "changeset version && node scripts/sync-server-json.mjs && node scripts/sync-lockfile-version.mjs",
|
|
18
18
|
"test": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest --run",
|
|
19
19
|
"test:unit": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest --run test/unit/ test/property/",
|
|
20
20
|
"test:integration": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest --run test/integration/",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"build"
|
|
28
28
|
],
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
30
|
+
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
31
31
|
"@opentelemetry/api": "^1.9.1",
|
|
32
32
|
"@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
|
|
33
33
|
"@opentelemetry/resources": "^2.9.0",
|