ssh-mcp 2.18.0 → 2.19.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/README.md
CHANGED
|
@@ -595,9 +595,19 @@ there. ``), so the caller can see which spelling of the same command still works
|
|
|
595
595
|
|
|
596
596
|
A separate **forbidden** list is never allowed, whatever the role or approval
|
|
597
597
|
policy: `rm -rf /`, `mkfs`, `dd of=/dev/`, `shutdown`, `curl|sh`, fork bombs,
|
|
598
|
-
writes
|
|
599
|
-
recursive `chmod 777 /` / `chown /`.
|
|
600
|
-
|
|
598
|
+
writes naming `/etc/cron`, `/etc/systemd` or any user's `authorized_keys`,
|
|
599
|
+
`iptables -F`, and recursive `chmod 777 /` / `chown /`. A write *names* one of
|
|
600
|
+
those paths when it is the remote path of an upload tool, the target of a shell
|
|
601
|
+
redirect, or a destination of `tee`, `cp`, `mv`, `install` or `dd of=` — in any
|
|
602
|
+
spelling the path readings above resolve. The two `/etc` families match by
|
|
603
|
+
prefix, so `/etc/crontab` is covered along with everything under `/etc/cron.d`,
|
|
604
|
+
and a sibling that merely starts like the directory (say `/etc/cronography`) is
|
|
605
|
+
refused too — the fail-closed direction. Writes spelled any other way (`sed -i`,
|
|
606
|
+
`rsync`, cmd.exe's `copy`, `move` and `robocopy`, a program handed to an
|
|
607
|
+
interpreter, a path the target computes) are not on this list; class and approval
|
|
608
|
+
govern them, and the operator denylist can close them. Add your own patterns via
|
|
609
|
+
the policy denylist; an invalid pattern fails at
|
|
610
|
+
startup rather than degrading silently.
|
|
601
611
|
|
|
602
612
|
### Approval Modes
|
|
603
613
|
|
|
@@ -693,8 +703,10 @@ The request shape is modeled on the AuthZEN Access Evaluation contract:
|
|
|
693
703
|
For the five SFTP tools, `resource` also carries three path keys: `remotePath`,
|
|
694
704
|
the path as given; and `remotePathNormalized` and `remotePathWindows`, the same
|
|
695
705
|
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
|
-
|
|
706
|
+
without parsing `command` or reimplementing either reading in Rego. The two
|
|
707
|
+
upload tools add `remoteWrite: true`, the fact the local policy refuses the
|
|
708
|
+
never-allowed write paths on — a rule can gate writes without parsing `command`
|
|
709
|
+
either. Other tools omit all four, and so does an empty path. `remotePath` is **as given** —
|
|
698
710
|
`/root//.ssh/x` arrives spelled that way, so a `startswith` rule on a directory
|
|
699
711
|
misses it: match trailing segments, or match the normalized keys.
|
|
700
712
|
`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:
|
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
|
},
|
|
@@ -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 });
|
|
@@ -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.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",
|