sshfc 0.1.0__py3-none-any.whl
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.
- sshfc/__init__.py +4 -0
- sshfc/cli.py +160 -0
- sshfc/core/__init__.py +30 -0
- sshfc/core/audit.py +77 -0
- sshfc/core/check.py +104 -0
- sshfc/core/config.py +329 -0
- sshfc/core/executor.py +58 -0
- sshfc/core/parser.py +240 -0
- sshfc/core/spec.py +87 -0
- sshfc/core/validators.py +115 -0
- sshfc/samples/podman-lifecycle.toml +23 -0
- sshfc/samples/podman-readonly.toml +74 -0
- sshfc/samples/system-info.toml +17 -0
- sshfc-0.1.0.dist-info/METADATA +312 -0
- sshfc-0.1.0.dist-info/RECORD +18 -0
- sshfc-0.1.0.dist-info/WHEEL +4 -0
- sshfc-0.1.0.dist-info/entry_points.txt +3 -0
- sshfc-0.1.0.dist-info/licenses/LICENSE +21 -0
sshfc/core/spec.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Copyright (C) 2026 Stephen T. Jibson.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Declarative specification of allowed commands.
|
|
5
|
+
|
|
6
|
+
This is the entire API a command author needs. A command is a tree of ``Command`` nodes; leaves may take
|
|
7
|
+
``Option``/``Flag``/``Positional`` arguments. The infrastructure turns the tree into a strict parser and, for an
|
|
8
|
+
accepted command, renders the exact argv to run.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
|
|
20
|
+
#: Validates (and may canonicalize) one argument value; raise ``argparse.ArgumentTypeError`` to reject it.
|
|
21
|
+
type Validator = Callable[[str], str]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Option:
|
|
26
|
+
"""An option that takes a value, e.g. ``--format VALUE``. Rendered as ``[name, value]`` when given.
|
|
27
|
+
|
|
28
|
+
``required`` makes the SSH user type it, e.g. to bound a query (``journalctl --since``).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
help: str
|
|
33
|
+
validator: Validator
|
|
34
|
+
required: bool = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Flag:
|
|
39
|
+
"""A boolean option, e.g. ``--all``. Rendered as ``[name]`` when given.
|
|
40
|
+
|
|
41
|
+
``required`` makes the SSH user type it, e.g. ``podman stats --no-stream``, so what runs is always what they
|
|
42
|
+
typed rather than something sshfc adds silently.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
name: str
|
|
46
|
+
help: str
|
|
47
|
+
required: bool = False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Positional:
|
|
52
|
+
"""A required positional argument. Rendered after the options (and after ``--``; see ``Command``)."""
|
|
53
|
+
|
|
54
|
+
name: str
|
|
55
|
+
help: str
|
|
56
|
+
validator: Validator
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class Command:
|
|
61
|
+
"""A command or subcommand.
|
|
62
|
+
|
|
63
|
+
Attributes:
|
|
64
|
+
name: The name the SSH user types.
|
|
65
|
+
help: One-line description, shown in help output.
|
|
66
|
+
run: Absolute path of the program to run. Inherited by subcommands, whose names are appended to the argv,
|
|
67
|
+
e.g. ``run="/usr/bin/podman"`` on ``podman`` makes ``podman pod ps`` run ``/usr/bin/podman pod ps``.
|
|
68
|
+
args: Options, flags and positionals. Only leaf commands (no ``subcommands``) may have them.
|
|
69
|
+
subcommands: Child commands. When present, one of them is required.
|
|
70
|
+
end_of_options: Put ``--`` before positionals so a value can never be read as an option. Disable only for
|
|
71
|
+
programs that don't understand ``--``.
|
|
72
|
+
accept: Example command lines (full, from the top-level name) that must be accepted. Use a
|
|
73
|
+
``(command_line, expected_argv)`` tuple to also check the rendered argv. Run by ``sshfc --check``.
|
|
74
|
+
reject: Example command lines that must be rejected. Run by ``sshfc --check``.
|
|
75
|
+
source: Where this command was defined (e.g. a policy file name); set by the config loader, used in errors and
|
|
76
|
+
audit records.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
name: str
|
|
80
|
+
help: str
|
|
81
|
+
run: str | None = None
|
|
82
|
+
args: Sequence[Option | Flag | Positional] = ()
|
|
83
|
+
subcommands: Sequence[Command] = ()
|
|
84
|
+
end_of_options: bool = True
|
|
85
|
+
accept: Sequence[str | tuple[str, str]] = ()
|
|
86
|
+
reject: Sequence[str] = ()
|
|
87
|
+
source: str | None = field(default=None, compare=False)
|
sshfc/core/validators.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Copyright (C) 2026 Stephen T. Jibson.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Argument validators for command specs."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import re
|
|
10
|
+
from typing import TYPE_CHECKING, Final
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Pattern:
|
|
17
|
+
"""Validator that accepts a value only if it fully matches a regex."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, regex: str, description: str) -> None:
|
|
20
|
+
"""Initialize the validator; ``description`` is shown to the SSH user when a value is rejected."""
|
|
21
|
+
self._regex = re.compile(regex)
|
|
22
|
+
self._description = description
|
|
23
|
+
|
|
24
|
+
def __call__(self, value: str) -> str:
|
|
25
|
+
"""Validate a value."""
|
|
26
|
+
if not self._regex.fullmatch(value):
|
|
27
|
+
msg = f"invalid value: {value!r} (expected {self._description})"
|
|
28
|
+
raise argparse.ArgumentTypeError(msg)
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
#: A podman/docker container, image, or pod name or ID (no tags, digests, or registry paths).
|
|
33
|
+
CONTAINER_NAME: Final = Pattern(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", "a container, image, or pod name or ID")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GoTemplateFormat:
|
|
37
|
+
"""Argparse ``type=`` callable that accepts only a restricted subset of Go template syntax.
|
|
38
|
+
|
|
39
|
+
A Go template is a small program (``{{json .}}``, ``{{range ...}}``, ``{{.Config.Env}}``, ...), so this does not
|
|
40
|
+
try to recognize "safe" templates. Instead, it only accepts:
|
|
41
|
+
|
|
42
|
+
- an optional leading ``table`` keyword,
|
|
43
|
+
- placeholders of the exact form ``{{.Field}}`` (whitespace inside the braces allowed), where ``Field`` is in an
|
|
44
|
+
explicit allowlist -- no functions, pipelines, nested fields, or control structures,
|
|
45
|
+
- literal text between placeholders, made of a small set of characters that excludes ``{`` and ``}``, so literal
|
|
46
|
+
text can never open a template action.
|
|
47
|
+
|
|
48
|
+
The value returned to argparse is rebuilt from the parsed pieces, not the caller's original string.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
_PLACEHOLDER: Final = re.compile(r"\{\{\s*\.([A-Za-z][A-Za-z0-9]*)\s*\}\}")
|
|
52
|
+
_LITERAL: Final = re.compile(r"[A-Za-z0-9 \t:|,;=/._-]*")
|
|
53
|
+
_TABLE_PREFIX: Final = re.compile(r"table\s+")
|
|
54
|
+
|
|
55
|
+
def __init__(self, fields: Iterable[str], *, max_length: int = 256, max_fields: int = 16) -> None:
|
|
56
|
+
"""Initialize the validator with the allowed field names and size limits."""
|
|
57
|
+
self._fields = frozenset(fields)
|
|
58
|
+
self._max_length = max_length
|
|
59
|
+
self._max_fields = max_fields
|
|
60
|
+
self.__name__ = "format" # Used by argparse in some error messages.
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def fields(self) -> list[str]:
|
|
64
|
+
"""Return the allowed field names, sorted."""
|
|
65
|
+
return sorted(self._fields)
|
|
66
|
+
|
|
67
|
+
def __call__(self, value: str) -> str:
|
|
68
|
+
"""Validate a format string and return a canonical copy of it."""
|
|
69
|
+
if len(value) > self._max_length:
|
|
70
|
+
msg = f"format is longer than {self._max_length} characters"
|
|
71
|
+
raise argparse.ArgumentTypeError(msg)
|
|
72
|
+
|
|
73
|
+
prefix = ""
|
|
74
|
+
if table := self._TABLE_PREFIX.match(value):
|
|
75
|
+
prefix = "table "
|
|
76
|
+
value = value[table.end() :]
|
|
77
|
+
|
|
78
|
+
parts: list[str] = []
|
|
79
|
+
fields: list[str] = []
|
|
80
|
+
pos = 0
|
|
81
|
+
for match in self._PLACEHOLDER.finditer(value):
|
|
82
|
+
parts.append(self._literal(value[pos : match.start()]))
|
|
83
|
+
field = match.group(1)
|
|
84
|
+
if field not in self._fields:
|
|
85
|
+
msg = f"field not allowed: {field!r} (choose from {', '.join(self.fields)})"
|
|
86
|
+
raise argparse.ArgumentTypeError(msg)
|
|
87
|
+
fields.append(field)
|
|
88
|
+
parts.append(f"{{{{.{field}}}}}")
|
|
89
|
+
pos = match.end()
|
|
90
|
+
parts.append(self._literal(value[pos:]))
|
|
91
|
+
|
|
92
|
+
if not fields:
|
|
93
|
+
msg = "format must contain at least one {{.Field}} placeholder"
|
|
94
|
+
raise argparse.ArgumentTypeError(msg)
|
|
95
|
+
if len(fields) > self._max_fields:
|
|
96
|
+
msg = f"format has more than {self._max_fields} placeholders"
|
|
97
|
+
raise argparse.ArgumentTypeError(msg)
|
|
98
|
+
|
|
99
|
+
return prefix + "".join(parts)
|
|
100
|
+
|
|
101
|
+
def _literal(self, text: str) -> str:
|
|
102
|
+
"""Validate literal text between placeholders."""
|
|
103
|
+
if not self._LITERAL.fullmatch(text):
|
|
104
|
+
msg = (
|
|
105
|
+
f"invalid template text: {text!r} "
|
|
106
|
+
"(only {{.Field}} placeholders, letters, digits, spaces, tabs and :|,;=/._- are allowed)"
|
|
107
|
+
)
|
|
108
|
+
raise argparse.ArgumentTypeError(msg)
|
|
109
|
+
return text
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
#: Validators that policy files can refer to by name (``validator = "container_name"``).
|
|
113
|
+
NAMED: Final = {
|
|
114
|
+
"container_name": CONTAINER_NAME,
|
|
115
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Podman, state-changing: start, stop and restart containers.
|
|
2
|
+
|
|
3
|
+
# Shared with podman-readonly.toml; it must be identical in every file that declares it.
|
|
4
|
+
[commands.podman]
|
|
5
|
+
help = "Manage pods, containers and images."
|
|
6
|
+
run = "/usr/bin/podman"
|
|
7
|
+
|
|
8
|
+
[commands."podman restart"]
|
|
9
|
+
help = "Restart a container."
|
|
10
|
+
positionals = [{ name = "container", help = "Container name or ID.", validator = "container_name" }]
|
|
11
|
+
accept = [{ command = "podman restart web-1", argv = "/usr/bin/podman restart -- web-1" }]
|
|
12
|
+
reject = ["podman restart", "podman restart --all", "podman restart web-1 web-2"]
|
|
13
|
+
|
|
14
|
+
[commands."podman start"]
|
|
15
|
+
help = "Start a container."
|
|
16
|
+
positionals = [{ name = "container", help = "Container name or ID.", validator = "container_name" }]
|
|
17
|
+
accept = [{ command = "podman start web-1", argv = "/usr/bin/podman start -- web-1" }]
|
|
18
|
+
|
|
19
|
+
[commands."podman stop"]
|
|
20
|
+
help = "Stop a container."
|
|
21
|
+
positionals = [{ name = "container", help = "Container name or ID.", validator = "container_name" }]
|
|
22
|
+
accept = [{ command = "podman stop web-1", argv = "/usr/bin/podman stop -- web-1" }]
|
|
23
|
+
reject = ["podman stop 'web;id'"]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Podman, read-only: list and inspect containers, images and pods; resource usage; processes.
|
|
2
|
+
|
|
3
|
+
# Shared with podman-lifecycle.toml; it must be identical in every file that declares it.
|
|
4
|
+
[commands.podman]
|
|
5
|
+
help = "Manage pods, containers and images."
|
|
6
|
+
run = "/usr/bin/podman"
|
|
7
|
+
reject = ["podman", "podman rm web-1", "podman --remote ps"]
|
|
8
|
+
|
|
9
|
+
[commands."podman images"]
|
|
10
|
+
help = "List images."
|
|
11
|
+
accept = [{ command = "podman images", argv = "/usr/bin/podman images" }]
|
|
12
|
+
|
|
13
|
+
[commands."podman inspect"]
|
|
14
|
+
help = "Display the raw configuration of a container, image or pod."
|
|
15
|
+
positionals = [{ name = "name", help = "Container, image or pod to inspect.", validator = "container_name" }]
|
|
16
|
+
accept = [{ command = "podman inspect web-1", argv = "/usr/bin/podman inspect -- web-1" }]
|
|
17
|
+
reject = ["podman inspect", "podman inspect --format=x web-1", "podman inspect 'web-1;id'"]
|
|
18
|
+
|
|
19
|
+
[commands."podman pod"]
|
|
20
|
+
help = "Manage pods."
|
|
21
|
+
reject = ["podman pod", "podman pod rm mypod"]
|
|
22
|
+
|
|
23
|
+
[commands."podman pod ps"]
|
|
24
|
+
help = "List pods."
|
|
25
|
+
accept = [{ command = "podman pod ps", argv = "/usr/bin/podman pod ps" }]
|
|
26
|
+
|
|
27
|
+
[commands."podman pod inspect"]
|
|
28
|
+
help = "Inspect a pod."
|
|
29
|
+
positionals = [{ name = "pod", help = "Pod to inspect.", validator = "container_name" }]
|
|
30
|
+
accept = [{ command = "podman pod inspect mypod", argv = "/usr/bin/podman pod inspect -- mypod" }]
|
|
31
|
+
|
|
32
|
+
[commands."podman ps"]
|
|
33
|
+
help = "List containers."
|
|
34
|
+
accept = [
|
|
35
|
+
{ command = "podman ps", argv = "/usr/bin/podman ps" },
|
|
36
|
+
{ command = "podman ps --format '{{.ID}} {{.Image}} {{.Labels}} {{.Mounts}}'", argv = "/usr/bin/podman ps --format '{{.ID}} {{.Image}} {{.Labels}} {{.Mounts}}'" },
|
|
37
|
+
{ command = "podman ps --format='{{ .ID }}'", argv = "/usr/bin/podman ps --format '{{.ID}}'" },
|
|
38
|
+
]
|
|
39
|
+
reject = ["podman ps --all", "podman ps --format '{{.Command}}'", "podman ps --format '{{json .}}'"]
|
|
40
|
+
|
|
41
|
+
# Deliberately excluded: Command (container args often carry secrets). Labels can hold secrets too (e.g. reverse-proxy
|
|
42
|
+
# basic-auth hashes); remove it if that matters on your hosts.
|
|
43
|
+
[commands."podman ps".options."--format"]
|
|
44
|
+
help = "Go template using only {{.Field}} placeholders."
|
|
45
|
+
go_template = [
|
|
46
|
+
"CreatedAt",
|
|
47
|
+
"CreatedHuman",
|
|
48
|
+
"ID",
|
|
49
|
+
"Image",
|
|
50
|
+
"ImageID",
|
|
51
|
+
"Labels",
|
|
52
|
+
"Mounts",
|
|
53
|
+
"Names",
|
|
54
|
+
"Networks",
|
|
55
|
+
"Pod",
|
|
56
|
+
"PodName",
|
|
57
|
+
"Ports",
|
|
58
|
+
"RunningFor",
|
|
59
|
+
"State",
|
|
60
|
+
"Status",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
[commands."podman stats"]
|
|
64
|
+
help = "Display container resource usage statistics."
|
|
65
|
+
# A live stream never ends, which would leave the SSH session hanging, so --no-stream is required. The user types it,
|
|
66
|
+
# so what runs is exactly what they asked for.
|
|
67
|
+
flags."--no-stream" = { help = "Print one snapshot instead of a live stream (required).", required = true }
|
|
68
|
+
accept = [{ command = "podman stats --no-stream", argv = "/usr/bin/podman stats --no-stream" }]
|
|
69
|
+
reject = ["podman stats"]
|
|
70
|
+
|
|
71
|
+
[commands."podman top"]
|
|
72
|
+
help = "Display the running processes of a container."
|
|
73
|
+
positionals = [{ name = "container", help = "Container name or ID.", validator = "container_name" }]
|
|
74
|
+
accept = [{ command = "podman top web-1", argv = "/usr/bin/podman top -- web-1" }]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Basic system information: hostname, pwd, whoami.
|
|
2
|
+
|
|
3
|
+
[commands.hostname]
|
|
4
|
+
help = "Show the system's host name."
|
|
5
|
+
run = "/usr/bin/hostname"
|
|
6
|
+
accept = [{ command = "hostname", argv = "/usr/bin/hostname" }]
|
|
7
|
+
reject = ["hostname newname", "hostname -F /etc/shadow"]
|
|
8
|
+
|
|
9
|
+
[commands.pwd]
|
|
10
|
+
help = "Print the current working directory."
|
|
11
|
+
run = "/usr/bin/pwd"
|
|
12
|
+
accept = [{ command = "pwd", argv = "/usr/bin/pwd" }]
|
|
13
|
+
|
|
14
|
+
[commands.whoami]
|
|
15
|
+
help = "Print the user name associated with the current effective user ID."
|
|
16
|
+
run = "/usr/bin/whoami"
|
|
17
|
+
accept = [{ command = "whoami", argv = "/usr/bin/whoami" }]
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sshfc
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Force command validator for SSH.
|
|
5
|
+
Author: Steve Jibson
|
|
6
|
+
Author-email: Steve Jibson <steve@jibson.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Programming Language :: Python
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Topic :: Utilities
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Project-URL: Repository, https://github.com/toadstule/sshfc
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# sshfc
|
|
20
|
+
|
|
21
|
+
A command validator for SSH's `ForceCommand`. sshfc lets an SSH user (a person or an agent) run only an explicit
|
|
22
|
+
allowlist of commands, each with an explicit allowlist of arguments. Anything else is rejected with a short message
|
|
23
|
+
explaining why and what is allowed.
|
|
24
|
+
|
|
25
|
+
The allowlist is a **policy**: one or more TOML files in a directory, `/etc/sshfc/commands.d/` by default. sshfc ships
|
|
26
|
+
sample policy files you can drop in as they are or adapt.
|
|
27
|
+
|
|
28
|
+
> **Status: proof of concept.** Try a deployment with `--dry-run` first (see [Deploying](#deploying)).
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
```console
|
|
33
|
+
$ sshfc --samples # list the shipped sample policy files
|
|
34
|
+
$ sudo install -d -m 755 /etc/sshfc/commands.d
|
|
35
|
+
$ sshfc --samples podman-readonly | sudo tee /etc/sshfc/commands.d/podman-readonly.toml >/dev/null
|
|
36
|
+
$ sudo sshfc --check # check the policy and run its examples
|
|
37
|
+
$ sshfc --dry-run -c 'podman ps' # see what a command would run
|
|
38
|
+
Would run: /usr/bin/podman ps
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Then point an SSH key at sshfc (see [Deploying](#deploying)).
|
|
42
|
+
|
|
43
|
+
## Running sshfc
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
sshfc [--config PATH] [--dry-run] [-c COMMAND] [--key-id LABEL] [--insecure-config]
|
|
47
|
+
sshfc --check [--config PATH] [--insecure-config]
|
|
48
|
+
sshfc --samples [NAME]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
| Invocation | Command comes from | Action |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| `sshfc` | `$SSH_ORIGINAL_COMMAND` | Run it. This is the production mode. |
|
|
54
|
+
| `sshfc --dry-run` | `$SSH_ORIGINAL_COMMAND` | Print `Would run: ...` and run nothing. |
|
|
55
|
+
| `sshfc -c 'podman ps'` | `-c` | Run it. For local testing. |
|
|
56
|
+
| `sshfc --dry-run -c 'podman ps'` | `-c` | Print `Would run: ...` and run nothing. |
|
|
57
|
+
| `sshfc --check` | | Check the policy, run every example, report problems, and exit 1 if there are any. |
|
|
58
|
+
| `sshfc --samples [NAME]` | | List the sample policy files, or print one. |
|
|
59
|
+
|
|
60
|
+
- **Your options are separate from the SSH user's input.** sshfc's own options come from `command="..."` in
|
|
61
|
+
`authorized_keys` (or `ForceCommand` in `sshd_config`), so the admin controls them, never the SSH user.
|
|
62
|
+
- **`--config PATH`** is a policy directory or a single policy file. Different keys can use different policies.
|
|
63
|
+
- **`-c` takes a single string,** the same form as `SSH_ORIGINAL_COMMAND`, so local tests take the same path as
|
|
64
|
+
production. If both are present, `-c` wins.
|
|
65
|
+
- **A login with no command is rejected.** If neither is present, as with an interactive `ssh host`, sshfc rejects it
|
|
66
|
+
and lists the available commands.
|
|
67
|
+
- **Commands run without a shell,** with stdin, stdout and stderr passed through. sshfc exits with the program's
|
|
68
|
+
status, `128 + N` if it was killed by signal N, or 127/126 if the program can't be found or run.
|
|
69
|
+
- **The environment is minimal.** `PATH` is fixed to `/usr/bin:/bin`. `HOME`, `USER` and `LOGNAME` come from the
|
|
70
|
+
passwd entry. Only `LANG`, `LC_ALL`, `TERM` and `XDG_RUNTIME_DIR` (needed by rootless podman) are passed through.
|
|
71
|
+
|
|
72
|
+
### Policy security
|
|
73
|
+
|
|
74
|
+
- **Ownership:** the policy directory, every policy file, the target of any symlink, and every directory above them
|
|
75
|
+
must be owned by root and not writable by group or others. A world-writable ancestor is allowed only if it has the
|
|
76
|
+
sticky bit, like `/tmp`. Otherwise the SSH account could change its own policy.
|
|
77
|
+
- **`--insecure-config`** skips these checks so you can try a policy from a checkout. It's accepted only with
|
|
78
|
+
`--dry-run` or `--check`, so nothing can run under an unchecked policy.
|
|
79
|
+
- **Fail closed:** if the policy is missing, insecure or malformed, every command is rejected. The SSH user sees only
|
|
80
|
+
`SSH command rejected: sshfc is misconfigured; contact the administrator`. The details go to the audit log as a
|
|
81
|
+
`config_error` record. Run `sshfc --check` to see them.
|
|
82
|
+
|
|
83
|
+
### Deploying
|
|
84
|
+
|
|
85
|
+
1. Create a dedicated, low-privilege account.
|
|
86
|
+
2. Install a policy in `/etc/sshfc/commands.d/` (see [Quick start](#quick-start)), and run `sudo sshfc --check`.
|
|
87
|
+
3. In the account's `~/.ssh/authorized_keys`, prefix each key with:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
command="/path/to/sshfc --dry-run",restrict ssh-ed25519 AAAA... user@example
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
4. Try commands through real SSH (`ssh host podman ps`) and check the `Would run:` output.
|
|
94
|
+
5. Remove `--dry-run`.
|
|
95
|
+
|
|
96
|
+
`restrict` turns off port, agent and X11 forwarding, PTY allocation, and `~/.ssh/rc`.
|
|
97
|
+
|
|
98
|
+
If several people's keys share the account, give each key its own label so audit records show whose key was used. A
|
|
99
|
+
key can also get its own policy:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
command="/path/to/sshfc --key-id alice",restrict ssh-ed25519 AAAA... alice@example
|
|
103
|
+
command="/path/to/sshfc --key-id ci --config /etc/sshfc/ci.d",restrict ssh-ed25519 AAAA... ci@example
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Audit log
|
|
107
|
+
|
|
108
|
+
Every invocation is logged to syslog with the ident `sshfc`, under facility `authpriv` (`auth` where `authpriv`
|
|
109
|
+
isn't available). That puts the records next to sshd's own: `/var/log/auth.log` or `/var/log/secure`, or
|
|
110
|
+
`journalctl -t sshfc`. Each record is one JSON line:
|
|
111
|
+
|
|
112
|
+
| Event | When | Priority | Fields (besides `user`, `key_id`, `client`) |
|
|
113
|
+
|---|---|---|---|
|
|
114
|
+
| `rejected` | The command isn't allowed. A login with no command has `source` `none`. | warning | `source`, `command`, `reason`, `dry_run` |
|
|
115
|
+
| `accepted` | Before the command runs, so there's a record even if it never finishes. Also logged for dry runs. | info | `source`, `command`, `argv`, `policy`, `dry_run` |
|
|
116
|
+
| `exited` | After the command finishes. | info | `argv`, `status`, `duration_s` |
|
|
117
|
+
| `config_error` | The policy couldn't be loaded, so the command was rejected without being checked. | error | `source`, `command`, `error`, `dry_run` |
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
{"event":"accepted","user":"svc","key_id":"alice","client":"192.0.2.7 51234","source":"SSH_ORIGINAL_COMMAND","command":"podman ps --format='{{ .ID }}'","argv":["/usr/bin/podman","ps","--format","{{.ID}}"],"policy":"podman-readonly.toml","dry_run":false}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
- **`command` vs `argv`:** `command` is exactly what the SSH user sent, and `argv` is what sshfc ran.
|
|
124
|
+
- **`policy`:** the policy file (or files) that define the matched command.
|
|
125
|
+
- **`client`:** the client address and port from `SSH_CONNECTION`.
|
|
126
|
+
- **Log injection:** records are JSON with every non-ASCII and control character escaped, so a crafted command can't
|
|
127
|
+
split a record or forge a new one.
|
|
128
|
+
|
|
129
|
+
## How it works
|
|
130
|
+
|
|
131
|
+
sshfc builds a strict parser from the policy and:
|
|
132
|
+
|
|
133
|
+
1. Splits the requested command line using shell rules (`shlex`). Nothing is passed to a shell.
|
|
134
|
+
2. Parses it against the allowed commands. Unknown commands, subcommands, options and arguments are rejected.
|
|
135
|
+
Abbreviated options are rejected too, so `--for` is not treated as `--format`.
|
|
136
|
+
3. Runs every option and positional value through a validator, such as a name pattern or a restricted Go template.
|
|
137
|
+
4. **Rebuilds** the argv from the parsed result, not from the original string. The argv is the absolute path of the
|
|
138
|
+
program, then the subcommand path, then the options in canonical form, then `--` and the positionals.
|
|
139
|
+
|
|
140
|
+
Because of step 4, sshfc runs exactly what it validated. A positional value can never be read as an option by the
|
|
141
|
+
target program, and a user can't add arguments the policy doesn't allow.
|
|
142
|
+
|
|
143
|
+
sshfc also never adds arguments the user didn't type. What runs is what they asked for, in canonical form, so
|
|
144
|
+
commands behave the way users of the real tool expect. When a flag must always be present, the policy makes it
|
|
145
|
+
**required** and the user types it (see `podman stats --no-stream` below).
|
|
146
|
+
|
|
147
|
+
## What SSH users see
|
|
148
|
+
|
|
149
|
+
With sshfc deployed with `--dry-run` and the sample policies:
|
|
150
|
+
|
|
151
|
+
```console
|
|
152
|
+
$ ssh host "podman ps --format '{{ .ID }} {{.Image}}'"
|
|
153
|
+
Would run: /usr/bin/podman ps --format '{{.ID}} {{.Image}}'
|
|
154
|
+
|
|
155
|
+
$ ssh host
|
|
156
|
+
SSH command rejected: the following arguments are required: command (choose from hostname, podman, pwd, whoami)
|
|
157
|
+
|
|
158
|
+
$ ssh host podman pod
|
|
159
|
+
SSH command rejected: podman pod -- the following arguments are required: subcommand (choose from inspect, ps)
|
|
160
|
+
|
|
161
|
+
$ ssh host "podman stop 'web;id'"
|
|
162
|
+
SSH command rejected: podman stop -- argument container: invalid value: 'web;id' (expected a container, image, or pod name or ID)
|
|
163
|
+
|
|
164
|
+
$ ssh host "podman ps --format '{{.Command}}'"
|
|
165
|
+
SSH command rejected: podman ps -- argument --format: field not allowed: 'Command' (choose from CreatedAt, ...)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Without `--dry-run`, an accepted command's output and exit status are the program's own.
|
|
169
|
+
|
|
170
|
+
Every rejection starts with `SSH command rejected:`, followed by the subcommand path where the error happened, and
|
|
171
|
+
exits with status 1. `-h`/`--help` works at every level and shows the available subcommands and options.
|
|
172
|
+
|
|
173
|
+
## Writing a policy
|
|
174
|
+
|
|
175
|
+
A policy file has one `[commands."<path>"]` table per command, keyed by the full command path, so even deep trees
|
|
176
|
+
stay flat:
|
|
177
|
+
|
|
178
|
+
```toml
|
|
179
|
+
# podman-readonly.toml (excerpt)
|
|
180
|
+
[commands.podman]
|
|
181
|
+
help = "Manage pods, containers and images."
|
|
182
|
+
run = "/usr/bin/podman"
|
|
183
|
+
reject = ["podman rm web-1"]
|
|
184
|
+
|
|
185
|
+
[commands."podman stop"]
|
|
186
|
+
help = "Stop a container."
|
|
187
|
+
positionals = [{ name = "container", help = "Container name or ID.", validator = "container_name" }]
|
|
188
|
+
accept = [{ command = "podman stop web-1", argv = "/usr/bin/podman stop -- web-1" }]
|
|
189
|
+
|
|
190
|
+
[commands."podman stats"]
|
|
191
|
+
help = "Display container resource usage statistics."
|
|
192
|
+
flags."--no-stream" = { help = "Print one snapshot instead of a live stream (required).", required = true }
|
|
193
|
+
|
|
194
|
+
[commands."podman ps".options."--format"]
|
|
195
|
+
help = "Go template using only {{.Field}} placeholders."
|
|
196
|
+
go_template = ["ID", "Image", "Names", "Status"]
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Run `sshfc --samples podman-readonly` for the complete file. After any change, run `sshfc --check`.
|
|
200
|
+
|
|
201
|
+
### Several files in one directory
|
|
202
|
+
|
|
203
|
+
- **Which files are read:** only `*.toml` files whose names don't start with `.`, in sorted order. Editor backups
|
|
204
|
+
(`*~`) and package-manager leftovers (`.rpmnew`, `.dpkg-old`) are ignored.
|
|
205
|
+
- **No overrides:** each command path is defined by one file. The same path may appear in several files only if
|
|
206
|
+
every definition is identical, which is how independent files share a parent such as `podman`. A difference is an
|
|
207
|
+
error naming both files. Because nothing overrides anything, the merged policy doesn't depend on file order, and a
|
|
208
|
+
later file can't quietly change a definition reviewed in another.
|
|
209
|
+
- **Examples are combined:** `accept`/`reject` examples aren't part of the definition. They're combined, and
|
|
210
|
+
`sshfc --check` runs them all against the **merged** policy. If one file allows something another file's `reject`
|
|
211
|
+
examples expect to be rejected, the check fails.
|
|
212
|
+
- **Commands are listed alphabetically** at each level in help and error messages.
|
|
213
|
+
|
|
214
|
+
### Command fields
|
|
215
|
+
|
|
216
|
+
| Field | Meaning |
|
|
217
|
+
|---|---|
|
|
218
|
+
| `help` | One-line description shown in help output and error messages. Required. |
|
|
219
|
+
| `run` | Absolute path of the program. Subcommands inherit it and their names are added to the argv, so `run = "/usr/bin/podman"` on `podman` makes `podman pod ps` run `/usr/bin/podman pod ps`. Every leaf command needs a `run` on itself or an ancestor. |
|
|
220
|
+
| `options` | Options that take a value: `options."--name" = { help = "...", <validator> }`. Rendered as `--name VALUE`, if given. Add `required = true` to make the user type it. |
|
|
221
|
+
| `flags` | Options without a value: `flags."--all" = { help = "..." }`. Rendered as `--all`, if given. Add `required = true` to make the user type it. |
|
|
222
|
+
| `positionals` | Required positional arguments, in order: `[{ name = "...", help = "...", <validator> }]`. Rendered after `--`. |
|
|
223
|
+
| `end_of_options` | Put `--` before positionals (default `true`). Turn it off only for programs that don't understand `--`. |
|
|
224
|
+
| `accept` | Full example command lines that must be accepted. Use `{ command = "...", argv = "..." }` to also check what would run. |
|
|
225
|
+
| `reject` | Full example command lines that must be rejected. |
|
|
226
|
+
|
|
227
|
+
Only leaf commands (commands without subcommands) can have `options`, `flags` or `positionals`. A command with
|
|
228
|
+
subcommands requires the user to choose one. Unknown keys are errors, so a typo can't be silently ignored.
|
|
229
|
+
|
|
230
|
+
Options and flags are always rendered in the order the policy lists them, whatever order the user typed them in.
|
|
231
|
+
|
|
232
|
+
A **required** option or flag must be typed. If it's missing, the error names it
|
|
233
|
+
(`SSH command rejected: podman stats -- the following arguments are required: --no-stream`), and `-h` shows its help.
|
|
234
|
+
Use it when a command is only safe or useful with a particular argument. For example, without `--no-stream`,
|
|
235
|
+
`podman stats` streams forever and the SSH session never ends. Put the reason in the help text, since that's what the
|
|
236
|
+
user sees.
|
|
237
|
+
|
|
238
|
+
### Validators
|
|
239
|
+
|
|
240
|
+
Every option and positional needs exactly one validator. There is no "any string" default.
|
|
241
|
+
|
|
242
|
+
| Validator | Accepts |
|
|
243
|
+
|---|---|
|
|
244
|
+
| `validator = "container_name"` | A plain container, image or pod name or ID. Tags, digests, registry paths, and anything starting with `-` or `.` are rejected. |
|
|
245
|
+
| `pattern = "<regex>"`, `description = "..."` | Values that **fully** match the regex. `description` is shown to the user when a value is rejected. |
|
|
246
|
+
| `go_template = ["Field", ...]` | A restricted Go template for `--format`-style options (details below). The fields are added to the help text. |
|
|
247
|
+
|
|
248
|
+
`go_template` accepts:
|
|
249
|
+
|
|
250
|
+
- `{{.Field}}` placeholders, where `Field` must be in the list;
|
|
251
|
+
- literal text made of letters, digits, spaces, tabs and `:|,;=/._-`;
|
|
252
|
+
- an optional leading `table` keyword.
|
|
253
|
+
|
|
254
|
+
It rejects functions (`json`, `printf`), pipelines, nested fields, `range`/`if`, variables, comments and stray braces,
|
|
255
|
+
and enforces limits on length and placeholder count. The template sshfc runs is rebuilt from the parsed pieces, not
|
|
256
|
+
copied from the input.
|
|
257
|
+
|
|
258
|
+
When choosing which fields or values to allow, think about what they reveal. For example, the podman sample omits
|
|
259
|
+
`.Command` because container arguments often contain credentials.
|
|
260
|
+
|
|
261
|
+
### Python API
|
|
262
|
+
|
|
263
|
+
The policy loader produces `sshfc.core.Command` specs, and `sshfc.core` can also be used directly to build, check
|
|
264
|
+
and parse specs in Python. See `src/sshfc/core/spec.py`.
|
|
265
|
+
|
|
266
|
+
## Development
|
|
267
|
+
|
|
268
|
+
Requires Python 3.12+ and [uv](https://docs.astral.sh/uv/).
|
|
269
|
+
|
|
270
|
+
```console
|
|
271
|
+
make dep # install dependencies
|
|
272
|
+
make check # format, lint (ruff), type-check (mypy --strict), and test (pytest)
|
|
273
|
+
make build # build the wheel
|
|
274
|
+
make install # editable install into the active virtual environment
|
|
275
|
+
make help # list all targets
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Try the samples from a checkout (they aren't root-owned there, hence `--insecure-config`):
|
|
279
|
+
|
|
280
|
+
```console
|
|
281
|
+
uv run sshfc --check --insecure-config --config src/sshfc/samples
|
|
282
|
+
uv run sshfc --dry-run --insecure-config --config src/sshfc/samples -c "podman ps --format '{{.ID}}'"
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Project layout
|
|
286
|
+
|
|
287
|
+
```
|
|
288
|
+
src/sshfc/
|
|
289
|
+
cli.py entry point: modes, input source, auditing
|
|
290
|
+
core/ infrastructure (public API: sshfc.core)
|
|
291
|
+
spec.py Command, Option, Flag, Positional
|
|
292
|
+
config.py TOML policy loading, merging, ownership checks
|
|
293
|
+
check.py sshfc --check: load the policy, run every example against it
|
|
294
|
+
parser.py strict parser built from specs; spec checks; argv rendering
|
|
295
|
+
validators.py Pattern, CONTAINER_NAME, GoTemplateFormat, and the named validators
|
|
296
|
+
executor.py runs the rendered argv: no shell, minimal environment
|
|
297
|
+
audit.py JSON audit records to syslog
|
|
298
|
+
samples/ sample policy files, shipped in the package (sshfc --samples)
|
|
299
|
+
tests/
|
|
300
|
+
test_parser.py parser, using made-up specs
|
|
301
|
+
test_config.py policy loading, merge rules, schema errors, ownership checks
|
|
302
|
+
test_check.py example checks, including one file widening another
|
|
303
|
+
test_samples.py every sample, alone and all together
|
|
304
|
+
test_validators.py validators
|
|
305
|
+
test_executor.py executor: environment, exit statuses, failures to start
|
|
306
|
+
test_audit.py audit record format and log-injection safety
|
|
307
|
+
test_cli.py modes, input source, config errors, and what gets audited
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
## License
|
|
311
|
+
|
|
312
|
+
MIT
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
sshfc/__init__.py,sha256=b_mtl5I-3lYiCN_WfuDZQ2ri7j0HiqFURi1ArfTkG18,109
|
|
2
|
+
sshfc/cli.py,sha256=Z3ovNUfE-WgYj-y1l_0Nbqrz0aYLQopqulxoutf7EFg,6283
|
|
3
|
+
sshfc/core/__init__.py,sha256=m9iwTzeuWIWTUeVJ2ZnaFhYjtljzw64pchxQ4Ts3iKw,816
|
|
4
|
+
sshfc/core/audit.py,sha256=BaL-Dr5FB4St9WMldbORJ2p9umxBgDxKE6q9E3Jq5-w,3222
|
|
5
|
+
sshfc/core/check.py,sha256=SjJ9zylb2zlir0jLZkTLvOfElrxL7zeY8no2KHgiMrc,3934
|
|
6
|
+
sshfc/core/config.py,sha256=XAoXBBJlomN_JlbCMc0utvC2LLkDSDO20g09gp1GEp8,13474
|
|
7
|
+
sshfc/core/executor.py,sha256=I79xj2wKMpaxQn069LQu9rBkFjslOV5hHtM5E_2EwlM,2045
|
|
8
|
+
sshfc/core/parser.py,sha256=lMbr9QamKHimYCoPZ8obKgmEOcEU6KXlrQJ60zPzlGg,10692
|
|
9
|
+
sshfc/core/spec.py,sha256=cvYZsJRheGwlT-n9FL9gWnPkIq6lXTopSBKDZKMr56U,3157
|
|
10
|
+
sshfc/core/validators.py,sha256=mZV0NIup6e0X3lC2RYEiMzbHdi_ELEnyGc8Lbm9s178,4576
|
|
11
|
+
sshfc/samples/podman-lifecycle.toml,sha256=dEY400IoyfBE4IpB5TvakhwL-Lyk7bkRyV5mS62PMf8,1088
|
|
12
|
+
sshfc/samples/podman-readonly.toml,sha256=6HFJTo852yCai2KwnI_aJe1OPaY4KXfSRnBXTi-kPM8,3063
|
|
13
|
+
sshfc/samples/system-info.toml,sha256=8lPoWc4na4d3Z3utFlBveKm3vPgwagw2ri7FIwDT4sA,573
|
|
14
|
+
sshfc-0.1.0.dist-info/licenses/LICENSE,sha256=iXXwBn_F_lTlDMOlM1H8trV0cZNw835Yg4i-XZvzhQM,1074
|
|
15
|
+
sshfc-0.1.0.dist-info/WHEEL,sha256=si2p8vW03RnhyBnhWl4tj-ObJa78SpE-c9HsEtblGR0,80
|
|
16
|
+
sshfc-0.1.0.dist-info/entry_points.txt,sha256=Htq6ZHLBAaVgxnk48-XrqyyIdzG_9bWDXbj7PLSKQ2c,42
|
|
17
|
+
sshfc-0.1.0.dist-info/METADATA,sha256=_zDgjWLX7M6utdY9uAci0a_YHczI88EF1gYVHeOhYko,16357
|
|
18
|
+
sshfc-0.1.0.dist-info/RECORD,,
|