ssh-mcp 2.17.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 +79 -12
- package/build/policy/classifier.js +325 -6
- package/build/policy/engine.js +82 -14
- package/build/policy/never-allowed-writes.js +51 -0
- package/build/policy/remote-path.js +125 -0
- package/build/tools/command-tools.js +1 -1
- package/build/tools/file-tools.js +7 -11
- package/build/tools/pipeline.js +3 -3
- package/build/tools/transfer-tools.js +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -180,12 +180,13 @@ both and an auditor cannot tell which set of bytes landed. The digest is 128
|
|
|
180
180
|
bits rather than a short prefix because that claim has to hold against a caller
|
|
181
181
|
who picks both payloads, not only against an accidental repeat.
|
|
182
182
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
183
|
+
`[policy].denylist` patterns are tested against that whole string and, for every
|
|
184
|
+
SFTP tool, against the remote path on its own — as given, lexically normalized,
|
|
185
|
+
and read the way Windows reads it (see "Policy Engine"). A rule written for the
|
|
186
|
+
path, such as `authorized_keys$`, therefore does not depend on where the string
|
|
187
|
+
puts it or on how the file is spelled. A rule anchored on the *whole* string
|
|
188
|
+
(`^sftp:upload /root/.*$`) still does: that layout is ours to change, so anchor
|
|
189
|
+
on the path instead.
|
|
189
190
|
|
|
190
191
|
`sftp-upload-file` and `sftp-download-file` stream between the remote host and
|
|
191
192
|
local disk instead. Neither the bytes nor a base64 encoding of them ever reaches
|
|
@@ -330,7 +331,7 @@ transferTimeoutMs = 60000 # per-profile override
|
|
|
330
331
|
# operator on prod and leaves operator's other tiers, and viewer and admin,
|
|
331
332
|
# on their defaults.
|
|
332
333
|
[policy]
|
|
333
|
-
denylist = ["^terraform\\s+destroy"]
|
|
334
|
+
denylist = ["^terraform\\s+destroy", "\\.ssh/authorized_keys$"]
|
|
334
335
|
|
|
335
336
|
[policy.roleBindings.operator]
|
|
336
337
|
prod = ["read-only", "safe", "destructive"]
|
|
@@ -491,9 +492,52 @@ never-allowed list rather than replacing it:
|
|
|
491
492
|
|
|
492
493
|
```toml
|
|
493
494
|
[policy]
|
|
494
|
-
denylist = ["^terraform\\s+destroy"]
|
|
495
|
+
denylist = ["^terraform\\s+destroy", "\\.ssh/authorized_keys$"]
|
|
495
496
|
```
|
|
496
497
|
|
|
498
|
+
Each pattern is a regular expression tested against the command string of every
|
|
499
|
+
call. For the five SFTP tools it is also tested against the remote path on its
|
|
500
|
+
own, three ways: as given; in a normalized reading, where `//` and `/./`
|
|
501
|
+
collapse, `..` resolves against the segment before it, a trailing `/` is
|
|
502
|
+
dropped, and `\` is read as `/`; and in a **Windows reading**, tested
|
|
503
|
+
case-insensitively. The refusal says which matched.
|
|
504
|
+
|
|
505
|
+
The Windows reading is what Win32 resolves a path to, measured over SFTP on
|
|
506
|
+
Windows 11 (build 26200): the case variants, a trailing run of dots and spaces,
|
|
507
|
+
and the `::$DATA` default-stream spelling all reach the same file, so a rule
|
|
508
|
+
that caught one spelling and not another was a bypass, not a distinction. On
|
|
509
|
+
top of the normalized reading it strips those spellings, roots a
|
|
510
|
+
drive-relative path (`C:x/…`) at its drive — where it actually resolves
|
|
511
|
+
depends on process state this server cannot see, and rooting is the fail-closed
|
|
512
|
+
reading — drops `\\?\` and `\\.\` device prefixes, and keeps a UNC root
|
|
513
|
+
(`\\server\share\…` → `//server/share/…`). Tested case-insensitively, it also
|
|
514
|
+
catches `Authorized_Keys` with a rule written for `authorized_keys`; on a POSIX
|
|
515
|
+
target those are different files, but erring toward refusal is the direction
|
|
516
|
+
this engine takes everywhere else too.
|
|
517
|
+
|
|
518
|
+
What the readings do not do:
|
|
519
|
+
|
|
520
|
+
- **resolve anything on the target.** A relative path stays relative, so a rule
|
|
521
|
+
anchored on `/root/` does not see `.ssh/authorized_keys`; symlinks are not
|
|
522
|
+
followed; and an 8.3 short name (`AUTHOR~1`) is not expanded — no lexical
|
|
523
|
+
reading can, it takes the target's own directory listing to know the long
|
|
524
|
+
name.
|
|
525
|
+
- **test the local path** of `sftp-upload-file` or `sftp-download-file` on its
|
|
526
|
+
own. That side is confined by `transferRoot`. The local path does end those two
|
|
527
|
+
tools' command strings, so a pattern anchored on the end of the string can
|
|
528
|
+
still match it there, as it always could.
|
|
529
|
+
|
|
530
|
+
So anchor a path rule on its trailing segments: `\.ssh/authorized_keys$` matches
|
|
531
|
+
the absolute, relative and Windows spellings alike — including the case,
|
|
532
|
+
trailing-dot and `::$DATA` variants of each. A pattern written for a command is
|
|
533
|
+
tested against SFTP paths too — `^rm` refuses an `sftp-download` of a file
|
|
534
|
+
called `rmlist.txt`.
|
|
535
|
+
|
|
536
|
+
One performance note: patterns are ordinary regular expressions run against
|
|
537
|
+
caller-supplied path bytes — bounded by the remote-path length limit, the same
|
|
538
|
+
exposure the command string has always had. Prefer anchored, linear patterns; a
|
|
539
|
+
catastrophically backtracking one stalls the tool call it matches.
|
|
540
|
+
|
|
497
541
|
Because role and tier names are free strings, nothing in the merge itself can
|
|
498
542
|
tell a new custom role from a misspelling of an existing one. A cross-check at
|
|
499
543
|
startup does, and these all fail there rather than at the point of use:
|
|
@@ -551,9 +595,19 @@ there. ``), so the caller can see which spelling of the same command still works
|
|
|
551
595
|
|
|
552
596
|
A separate **forbidden** list is never allowed, whatever the role or approval
|
|
553
597
|
policy: `rm -rf /`, `mkfs`, `dd of=/dev/`, `shutdown`, `curl|sh`, fork bombs,
|
|
554
|
-
writes
|
|
555
|
-
recursive `chmod 777 /` / `chown /`.
|
|
556
|
-
|
|
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.
|
|
557
611
|
|
|
558
612
|
### Approval Modes
|
|
559
613
|
|
|
@@ -633,7 +687,7 @@ during the outage, and the only signal is a stderr line MCP clients usually disc
|
|
|
633
687
|
"opa-unavailable"` so the audit record says the gate was down rather than implying a policy
|
|
634
688
|
refused the command.
|
|
635
689
|
|
|
636
|
-
The request shape
|
|
690
|
+
The request shape is modeled on the AuthZEN Access Evaluation contract:
|
|
637
691
|
|
|
638
692
|
```json
|
|
639
693
|
{
|
|
@@ -646,6 +700,19 @@ The request shape follows the AuthZEN Access Evaluation contract:
|
|
|
646
700
|
}
|
|
647
701
|
```
|
|
648
702
|
|
|
703
|
+
For the five SFTP tools, `resource` also carries three path keys: `remotePath`,
|
|
704
|
+
the path as given; and `remotePathNormalized` and `remotePathWindows`, the same
|
|
705
|
+
two readings the local denylist tests, so a rule can match a canonical spelling
|
|
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** —
|
|
710
|
+
`/root//.ssh/x` arrives spelled that way, so a `startswith` rule on a directory
|
|
711
|
+
misses it: match trailing segments, or match the normalized keys.
|
|
712
|
+
`remotePathWindows` keeps the case it was given; `lower()` both sides in the
|
|
713
|
+
policy for case-insensitive rules. Match fields rather than the whole
|
|
714
|
+
`resource` object — its keys vary by tool.
|
|
715
|
+
|
|
649
716
|
OPA responds with `{ "result": true/false }`. If OPA denies (`result: false`), the command is blocked even if the built-in engine allows it. If OPA is unreachable, the built-in engine's decision stands by default (fail-open, to avoid locking out access); `--opaFailClosed` refuses instead. A 200 that carries no boolean `result` counts as unreachable — that is what OPA answers for an undefined document, so a misnamed package or an unactivated bundle is an outage rather than consent.
|
|
650
717
|
|
|
651
718
|
Example Rego policy (`ssh-mcp.rego`):
|
|
@@ -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,5 +1,9 @@
|
|
|
1
1
|
import { classifyCommand, findForbiddenMatch, formatReadOnlyRejection } from './classifier.js';
|
|
2
|
+
import { normalizeRemotePath, normalizeRemotePathForWindows } from './remote-path.js';
|
|
3
|
+
import { findNeverAllowedWriteTarget, neverAllowedWriteLabel } from './never-allowed-writes.js';
|
|
2
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.';
|
|
3
7
|
/**
|
|
4
8
|
* How long to wait for the OPA sidecar before treating it as unavailable.
|
|
5
9
|
*
|
|
@@ -200,7 +204,11 @@ export class PolicyEngine {
|
|
|
200
204
|
opaTimeoutMs = DEFAULT_OPA_TIMEOUT_MS;
|
|
201
205
|
/** Rate-limits the fail-open warning so one outage can't flood stderr. */
|
|
202
206
|
lastOpaWarning = 0;
|
|
203
|
-
/**
|
|
207
|
+
/**
|
|
208
|
+
* The operator's patterns, compiled once — each twice: as written, and
|
|
209
|
+
* case-insensitively for the Windows reading of a remote path, where a
|
|
210
|
+
* case variant reaches the same file (#230). The built-ins live in classifier.ts.
|
|
211
|
+
*/
|
|
204
212
|
userPatterns;
|
|
205
213
|
constructor(rules = DEFAULT_RULES) {
|
|
206
214
|
this.rules = rules;
|
|
@@ -209,7 +217,7 @@ export class PolicyEngine {
|
|
|
209
217
|
// worse than a startup failure, because nothing surfaces the degradation.
|
|
210
218
|
this.userPatterns = (rules.denylist ?? []).map((pattern) => {
|
|
211
219
|
try {
|
|
212
|
-
return new RegExp(pattern);
|
|
220
|
+
return { exact: new RegExp(pattern), caseInsensitive: new RegExp(pattern, 'i') };
|
|
213
221
|
}
|
|
214
222
|
catch (err) {
|
|
215
223
|
throw new OperatorError(`Invalid denylist pattern ${JSON.stringify(pattern)}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -226,7 +234,7 @@ export class PolicyEngine {
|
|
|
226
234
|
this.opaUrl = url;
|
|
227
235
|
this.opaFailClosed = failClosed;
|
|
228
236
|
}
|
|
229
|
-
evaluate(command, profile,
|
|
237
|
+
evaluate(command, profile, resource = {}) {
|
|
230
238
|
const parsed = classifyCommand(command);
|
|
231
239
|
const allowedClasses = this.getAllowedClasses(profile);
|
|
232
240
|
const classAllowed = allowedClasses.includes(parsed.class);
|
|
@@ -235,7 +243,7 @@ export class PolicyEngine {
|
|
|
235
243
|
// since a grammar-rejected reader is allowed as `safe` for most roles and
|
|
236
244
|
// only refused one layer up, in the tool itself.
|
|
237
245
|
const readOnlyRejection = parsed.readOnlyRejection;
|
|
238
|
-
const denied = this.findDenyMatch(command);
|
|
246
|
+
const denied = this.findDenyMatch(command, resource);
|
|
239
247
|
if (denied) {
|
|
240
248
|
return {
|
|
241
249
|
decision: 'deny',
|
|
@@ -288,8 +296,8 @@ export class PolicyEngine {
|
|
|
288
296
|
readOnlyRejection,
|
|
289
297
|
};
|
|
290
298
|
}
|
|
291
|
-
async evaluateWithOpa(command, profile, toolName) {
|
|
292
|
-
const local = this.evaluate(command, profile,
|
|
299
|
+
async evaluateWithOpa(command, profile, toolName, resource = {}) {
|
|
300
|
+
const local = this.evaluate(command, profile, resource);
|
|
293
301
|
if (local.decision === 'deny') {
|
|
294
302
|
return local;
|
|
295
303
|
}
|
|
@@ -298,10 +306,23 @@ export class PolicyEngine {
|
|
|
298
306
|
}
|
|
299
307
|
try {
|
|
300
308
|
const parsed = classifyCommand(command);
|
|
309
|
+
// The path as given, plus both readings the local denylist tests, so a Rego rule
|
|
310
|
+
// can match a canonical spelling without reimplementing either here. Undefined for
|
|
311
|
+
// every tool but the SFTP ones — and for an empty path, which means no path — and
|
|
312
|
+
// JSON drops the keys then.
|
|
313
|
+
const remotePath = resource.remotePath || undefined;
|
|
301
314
|
const input = {
|
|
302
315
|
subject: { role: profile.role, profile: profile.name },
|
|
303
316
|
action: { tool: toolName, commandClass: parsed.class },
|
|
304
|
-
resource: {
|
|
317
|
+
resource: {
|
|
318
|
+
command: parsed.fullCommand,
|
|
319
|
+
binary: parsed.binary,
|
|
320
|
+
host: profile.host,
|
|
321
|
+
remotePath,
|
|
322
|
+
remoteWrite: resource.remoteWrite || undefined,
|
|
323
|
+
remotePathNormalized: remotePath === undefined ? undefined : normalizeRemotePath(remotePath),
|
|
324
|
+
remotePathWindows: remotePath === undefined ? undefined : normalizeRemotePathForWindows(remotePath),
|
|
325
|
+
},
|
|
305
326
|
context: { readOnly: profile.readOnly },
|
|
306
327
|
};
|
|
307
328
|
const resp = await fetch(`${this.opaUrl}/v1/data/ssh/mcp/allow`, {
|
|
@@ -485,18 +506,65 @@ export class PolicyEngine {
|
|
|
485
506
|
* group, the class and the allowed set; this path never got the same
|
|
486
507
|
* treatment.
|
|
487
508
|
*/
|
|
488
|
-
findDenyMatch(command) {
|
|
509
|
+
findDenyMatch(command, resource) {
|
|
489
510
|
const builtIn = findForbiddenMatch(command);
|
|
490
511
|
if (builtIn) {
|
|
491
|
-
return `Command matches a built-in never-allowed rule: ${builtIn}.
|
|
492
|
-
`This list cannot be switched off — the [policy].denylist key adds patterns, it does not remove these.`;
|
|
512
|
+
return `Command matches a built-in never-allowed rule: ${builtIn}. ${NEVER_ALLOWED_TRAILER}`;
|
|
493
513
|
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
+
}
|
|
523
|
+
}
|
|
524
|
+
// Nothing below can match: skip reading the path at all, which is the commonest
|
|
525
|
+
// config — the defaults carry no denylist.
|
|
526
|
+
if (this.userPatterns.length === 0)
|
|
527
|
+
return null;
|
|
528
|
+
// The path is tested on its own as well as inside the command string, because for the
|
|
529
|
+
// SFTP tools that string is ours to lay out and a rule written for the path should not
|
|
530
|
+
// depend on where we put it (#230). An empty path means none was passed.
|
|
531
|
+
const remotePath = resource.remotePath || undefined;
|
|
532
|
+
const normalized = remotePath === undefined ? undefined : normalizeRemotePath(remotePath);
|
|
533
|
+
const windows = remotePath === undefined ? undefined : normalizeRemotePathForWindows(remotePath);
|
|
534
|
+
const fromConfig = 'a pattern from [policy].denylist in your config file. ' +
|
|
535
|
+
'Remove or narrow it there to allow this command.';
|
|
536
|
+
for (const { exact, caseInsensitive } of this.userPatterns) {
|
|
537
|
+
if (exact.test(command)) {
|
|
538
|
+
return `Command matches /${exact.source}/, ${fromConfig}`;
|
|
539
|
+
}
|
|
540
|
+
if (remotePath === undefined)
|
|
541
|
+
continue;
|
|
542
|
+
if (exact.test(remotePath)) {
|
|
543
|
+
return `Remote path ${quotePath(remotePath)} matches /${exact.source}/, ${fromConfig}`;
|
|
544
|
+
}
|
|
545
|
+
if (normalized !== remotePath && exact.test(normalized)) {
|
|
546
|
+
return `Remote path ${quotePath(remotePath)} (read as ${quotePath(normalized)}) ` +
|
|
547
|
+
`matches /${exact.source}/, ${fromConfig}`;
|
|
548
|
+
}
|
|
549
|
+
// The Windows reading runs even when it string-equals the plain one, because the
|
|
550
|
+
// pattern is tested case-insensitively here and exactly above: on Windows the case
|
|
551
|
+
// variants are the same file (measured, see remote-path.ts).
|
|
552
|
+
if (caseInsensitive.test(windows)) {
|
|
553
|
+
return `Remote path ${quotePath(remotePath)} (read as ${quotePath(windows)}, the Windows reading) ` +
|
|
554
|
+
`matches /${caseInsensitive.source}/i, ${fromConfig}`;
|
|
498
555
|
}
|
|
499
556
|
}
|
|
500
557
|
return null;
|
|
501
558
|
}
|
|
502
559
|
}
|
|
560
|
+
/**
|
|
561
|
+
* A path as it may appear in a refusal, capped. The paths are caller-supplied and can be
|
|
562
|
+
* as long as `MAX_REMOTE_PATH_CHARS` (4096); the refusal is rethrown to the MCP client
|
|
563
|
+
* and written into the audit record, where the command string already carries the full
|
|
564
|
+
* spelling — so the tail gives up nothing an operator needs, and a refusal stops being
|
|
565
|
+
* the engine's most verbose sentence by an order of magnitude.
|
|
566
|
+
*/
|
|
567
|
+
function quotePath(path) {
|
|
568
|
+
const capped = path.length > 256 ? `${path.slice(0, 253)}…` : path;
|
|
569
|
+
return JSON.stringify(capped);
|
|
570
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A remote path as a `[policy].denylist` pattern should also see it (#230).
|
|
3
|
+
*
|
|
4
|
+
* Lexical only: nothing is asked of the target, so a relative path stays relative and a
|
|
5
|
+
* symlink is not followed. What it removes is the spelling that keeps a rule from seeing
|
|
6
|
+
* a path it was written for — `//`, `/./`, `..`, a trailing separator — and it reads `\`
|
|
7
|
+
* as a separator too, so a Windows target's `C:\Users\a\.ssh\authorized_keys` is also
|
|
8
|
+
* seen as `C:/Users/a/.ssh/authorized_keys`. On a POSIX target a filename containing `\`
|
|
9
|
+
* can then match where it did not before; that only ever widens a refusal.
|
|
10
|
+
*
|
|
11
|
+
* An absolute path — `/…`, or one starting with a drive letter — cannot climb above its
|
|
12
|
+
* root. A relative path keeps a leading `..` it has nothing to resolve against.
|
|
13
|
+
*
|
|
14
|
+
* Not `node:path`: `posix.normalize` lets `..` climb above a drive root (`C:/../x` →
|
|
15
|
+
* `x`) and keeps a trailing separator, and `win32.normalize` answers in backslashes —
|
|
16
|
+
* while a deny rule needs one reading, in forward slashes, that never climbs.
|
|
17
|
+
*/
|
|
18
|
+
const DRIVE_LETTER = /^[A-Za-z]:$/;
|
|
19
|
+
export function normalizeRemotePath(path) {
|
|
20
|
+
const segments = path.replace(/\\/g, '/').split('/');
|
|
21
|
+
let root = null;
|
|
22
|
+
if (segments[0] === '') {
|
|
23
|
+
root = '';
|
|
24
|
+
segments.shift();
|
|
25
|
+
}
|
|
26
|
+
else if (DRIVE_LETTER.test(segments[0])) {
|
|
27
|
+
root = segments.shift();
|
|
28
|
+
}
|
|
29
|
+
const kept = [];
|
|
30
|
+
for (const segment of segments) {
|
|
31
|
+
if (segment === '' || segment === '.')
|
|
32
|
+
continue;
|
|
33
|
+
if (segment === '..') {
|
|
34
|
+
if (kept.length > 0 && kept[kept.length - 1] !== '..')
|
|
35
|
+
kept.pop();
|
|
36
|
+
else if (root === null)
|
|
37
|
+
kept.push('..');
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
kept.push(segment);
|
|
41
|
+
}
|
|
42
|
+
if (root === null)
|
|
43
|
+
return kept.length > 0 ? kept.join('/') : '.';
|
|
44
|
+
if (root === '')
|
|
45
|
+
return kept.length > 0 ? `/${kept.join('/')}` : '/';
|
|
46
|
+
return kept.length > 0 ? `${root}/${kept.join('/')}` : `${root}/`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The same path as Win32 would resolve it, for a denylist that must not depend on how
|
|
50
|
+
* the caller spelled the file (#230).
|
|
51
|
+
*
|
|
52
|
+
* Every transformation here was measured over SFTP on the Windows test VM (Windows 11
|
|
53
|
+
* build 26200): the spellings below reached the same file as the canonical one, so a
|
|
54
|
+
* rule that caught one spelling and not the other was a bypass, not a distinction.
|
|
55
|
+
*
|
|
56
|
+
* - `\` is read as `/`, as above.
|
|
57
|
+
* - A trailing run of dots and spaces is dropped from every segment (`authorized_keys.`
|
|
58
|
+
* and `authorized_keys..` are the same file). A segment that is nothing but dots
|
|
59
|
+
* (`a.../b`) therefore disappears, the way Win32 reads it.
|
|
60
|
+
* - A `::` suffix is cut (`authorized_keys::$DATA` is the file's default stream, not a
|
|
61
|
+
* different file). A single `:` is left alone: that names a different stream, and the
|
|
62
|
+
* measurement showed it does not reach the same bytes.
|
|
63
|
+
* - A drive-relative spelling (`C:x/…`, resolved against the drive's current directory
|
|
64
|
+
* on Windows) is rooted at the drive. Where it actually resolves depends on process
|
|
65
|
+
* state this server cannot see; rooting is the fail-closed reading.
|
|
66
|
+
* - `\\?\` and `\\.\` device prefixes are dropped. SFTP itself rejects them, but a rule
|
|
67
|
+
* should not have to know that.
|
|
68
|
+
* - A UNC spelling keeps its root (`\\server\share\…` → `//server/share/…`), which the
|
|
69
|
+
* plain reading collapses; `..` cannot climb above the share.
|
|
70
|
+
*
|
|
71
|
+
* Case is deliberately untouched — the engine tests this reading case-insensitively,
|
|
72
|
+
* which also covers a case-variant spelling of an otherwise identical path.
|
|
73
|
+
*
|
|
74
|
+
* Two Windows equivalences remain outside any lexical reading, because resolving them
|
|
75
|
+
* needs the target's own directory state, and are documented residuals in the README:
|
|
76
|
+
* 8.3 short names (`AUTHOR~1`), and symlinks.
|
|
77
|
+
*
|
|
78
|
+
* On a POSIX target these readings can only widen a refusal: a file genuinely named
|
|
79
|
+
* `authorized_keys.` is a different file there, but refusing it is the safe direction,
|
|
80
|
+
* exactly as with `\` above.
|
|
81
|
+
*/
|
|
82
|
+
export function normalizeRemotePathForWindows(path) {
|
|
83
|
+
let slashed = path.replace(/\\/g, '/');
|
|
84
|
+
if (slashed.startsWith('//?/') || slashed.startsWith('//./'))
|
|
85
|
+
slashed = slashed.slice(4);
|
|
86
|
+
const segments = slashed.split('/');
|
|
87
|
+
let root = null;
|
|
88
|
+
let rest = segments;
|
|
89
|
+
if (segments[0] === '' && segments[1] === '') {
|
|
90
|
+
// A UNC root: the share is as high as `..` may climb.
|
|
91
|
+
root = `//${segments[2] ?? ''}/${segments[3] ?? ''}`;
|
|
92
|
+
rest = segments.slice(4);
|
|
93
|
+
}
|
|
94
|
+
else if (segments[0] === '') {
|
|
95
|
+
root = '/';
|
|
96
|
+
rest = segments.slice(1);
|
|
97
|
+
}
|
|
98
|
+
else if (/^[A-Za-z]:/.test(segments[0])) {
|
|
99
|
+
const drive = segments[0].slice(0, 2);
|
|
100
|
+
const after = segments[0].slice(2);
|
|
101
|
+
root = drive;
|
|
102
|
+
rest = after === '' ? segments.slice(1) : [after, ...segments.slice(1)];
|
|
103
|
+
}
|
|
104
|
+
const kept = [];
|
|
105
|
+
for (const segment of rest) {
|
|
106
|
+
if (segment === '' || segment === '.')
|
|
107
|
+
continue;
|
|
108
|
+
if (segment === '..') {
|
|
109
|
+
if (kept.length > 0 && kept[kept.length - 1] !== '..')
|
|
110
|
+
kept.pop();
|
|
111
|
+
else if (root === null)
|
|
112
|
+
kept.push('..');
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const plain = segment.split('::')[0].replace(/[ .]+$/, '');
|
|
116
|
+
if (plain === '')
|
|
117
|
+
continue;
|
|
118
|
+
kept.push(plain);
|
|
119
|
+
}
|
|
120
|
+
if (root === null)
|
|
121
|
+
return kept.length > 0 ? kept.join('/') : '.';
|
|
122
|
+
if (root === '/')
|
|
123
|
+
return kept.length > 0 ? `/${kept.join('/')}` : '/';
|
|
124
|
+
return kept.length > 0 ? `${root}/${kept.join('/')}` : `${root}/`;
|
|
125
|
+
}
|
|
@@ -54,7 +54,7 @@ export function registerCommandTools({ server, registry, policy }, pipeline) {
|
|
|
54
54
|
// `registry.getProfile()`, which refuses on an unconfigured server, and outside
|
|
55
55
|
// the pipeline's try that refusal escaped without an audit record. `preCheck`
|
|
56
56
|
// runs inside it.
|
|
57
|
-
const rawEval = policy.evaluate(cleanCmd, registry.getProfile(defaultProfileName(profile))
|
|
57
|
+
const rawEval = policy.evaluate(cleanCmd, registry.getProfile(defaultProfileName(profile)));
|
|
58
58
|
if (rawEval.decision === 'deny') {
|
|
59
59
|
throw new PolicyRefusedError(`POLICY_DENIED: ${rawEval.reason || 'Command not allowed'}`, rawEval);
|
|
60
60
|
}
|
|
@@ -42,23 +42,18 @@ export function registerFileTools({ server }, pipeline) {
|
|
|
42
42
|
profile: z.string().optional().describe('Profile name'),
|
|
43
43
|
}, { destructiveHint: true }, async ({ remotePath, content, profile }, extra) => {
|
|
44
44
|
return runAudited(
|
|
45
|
-
// Verb, then what the operation does, then the path it does it to
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
// the
|
|
49
|
-
//
|
|
50
|
-
// appended after the path instead, that rule silently stops matching and the
|
|
51
|
-
// refusal degrades to a prompt with no warning. A whole-string rule
|
|
52
|
-
// (`^sftp:upload /root.*$`) breaks either way — it is coupled to a format
|
|
53
|
-
// that is ours to change — so the layout is chosen for the form that is not.
|
|
54
|
-
// `sftp:list` and `sftp:download` carry no flags, so they already read this
|
|
55
|
-
// way and need no change.
|
|
45
|
+
// Verb, then what the operation does, then the path it does it to. The path
|
|
46
|
+
// ends the line so the string reads naturally in a prompt and an audit record;
|
|
47
|
+
// `[policy].denylist` no longer depends on that, since every SFTP tool hands
|
|
48
|
+
// its remote path to the engine on its own (#230). A rule anchored on the whole
|
|
49
|
+
// string (`^sftp:upload /root.*$`) is still coupled to this layout.
|
|
56
50
|
`sftp:upload${OVERWRITE_FLAG}${payloadSuffix(content)} ${remotePathForAudit(remotePath)}`, {
|
|
57
51
|
toolName: 'sftp-upload',
|
|
58
52
|
failureClass: 'destructive',
|
|
59
53
|
profile,
|
|
60
54
|
extra,
|
|
61
55
|
synthetic: true,
|
|
56
|
+
resource: { remotePath, remoteWrite: true },
|
|
62
57
|
preCheck: () => { sanitizeRemotePath(remotePath); },
|
|
63
58
|
}, async (rt) => {
|
|
64
59
|
await new SftpClient(rt.conn).upload({ remotePath, content });
|
|
@@ -81,6 +76,7 @@ export function registerFileTools({ server }, pipeline) {
|
|
|
81
76
|
profile,
|
|
82
77
|
extra,
|
|
83
78
|
synthetic: true,
|
|
79
|
+
resource: { remotePath },
|
|
84
80
|
preCheck: () => { sanitizeRemotePath(remotePath); },
|
|
85
81
|
}, async (rt) => {
|
|
86
82
|
const data = await new SftpClient(rt.conn).download({ remotePath });
|
package/build/tools/pipeline.js
CHANGED
|
@@ -35,13 +35,13 @@ export function createPipeline({ server, registry, policy, audit, approvalGrantT
|
|
|
35
35
|
async function resolveConn(profileName) {
|
|
36
36
|
return registry.getOrCreate(profileName);
|
|
37
37
|
}
|
|
38
|
-
async function checkPolicyAndApprove(command, profileName, toolName) {
|
|
38
|
+
async function checkPolicyAndApprove(command, profileName, toolName, resource = {}) {
|
|
39
39
|
const span = tracer.startSpan('policy.evaluate');
|
|
40
40
|
span.setAttribute('tool.name', toolName);
|
|
41
41
|
span.setAttribute('ssh.profile', profileName);
|
|
42
42
|
try {
|
|
43
43
|
const conn = await resolveConn(profileName);
|
|
44
|
-
const evaluation = await policy.evaluateWithOpa(command, conn.profile, toolName);
|
|
44
|
+
const evaluation = await policy.evaluateWithOpa(command, conn.profile, toolName, resource);
|
|
45
45
|
span.setAttribute('policy.decision', evaluation.decision);
|
|
46
46
|
span.setAttribute('command.class', evaluation.commandClass);
|
|
47
47
|
span.setAttribute('command.binary', evaluation.binary);
|
|
@@ -167,7 +167,7 @@ export function createPipeline({ server, registry, policy, audit, approvalGrantT
|
|
|
167
167
|
effective = opts.wrap(effective);
|
|
168
168
|
state.command = effective;
|
|
169
169
|
}
|
|
170
|
-
const { conn, evaluation, approver } = await checkPolicyAndApprove(effective, profileName, opts.toolName);
|
|
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
173
|
// A command that fell to `safe` because a reader's grammar rejected one
|
|
@@ -207,6 +207,7 @@ export function registerTransferTools({ server, localPath }, pipeline) {
|
|
|
207
207
|
profile,
|
|
208
208
|
extra,
|
|
209
209
|
synthetic: true,
|
|
210
|
+
resource: { remotePath },
|
|
210
211
|
preCheck: () => { sanitizeRemotePath(remotePath); },
|
|
211
212
|
}, async (rt) => {
|
|
212
213
|
const budget = rt.conn.profile.maxOutputBytes;
|
|
@@ -255,6 +256,7 @@ export function registerTransferTools({ server, localPath }, pipeline) {
|
|
|
255
256
|
profile,
|
|
256
257
|
extra,
|
|
257
258
|
synthetic: true,
|
|
259
|
+
resource: { remotePath, remoteWrite: true },
|
|
258
260
|
preCheck: () => { sanitizeRemotePath(remotePath); checkMode(mode); },
|
|
259
261
|
}, async (rt) => {
|
|
260
262
|
const maxBytes = rt.conn.profile.transferMaxBytes;
|
|
@@ -306,6 +308,7 @@ export function registerTransferTools({ server, localPath }, pipeline) {
|
|
|
306
308
|
profile,
|
|
307
309
|
extra,
|
|
308
310
|
synthetic: true,
|
|
311
|
+
resource: { remotePath },
|
|
309
312
|
preCheck: () => { sanitizeRemotePath(remotePath); },
|
|
310
313
|
}, async (rt) => {
|
|
311
314
|
// As on the upload side, and for the same reason: refined before the
|
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",
|