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/__init__.py
ADDED
sshfc/cli.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# Copyright (C) 2026 Stephen T. Jibson.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""CLI entrypoint for sshfc.
|
|
5
|
+
|
|
6
|
+
sshfc's own arguments come from ``command="..."`` in authorized_keys (or ``ForceCommand`` in sshd_config), so they
|
|
7
|
+
are controlled by the admin, never by the SSH user. The SSH user's requested command comes from
|
|
8
|
+
``SSH_ORIGINAL_COMMAND``, or from ``-c`` for local testing.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import os
|
|
15
|
+
import shlex
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from importlib import resources
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
from sshfc.core.audit import AuditLog
|
|
23
|
+
from sshfc.core.check import check_policy
|
|
24
|
+
from sshfc.core.config import DEFAULT_PATH, ConfigError, load_policy
|
|
25
|
+
from sshfc.core.executor import execute
|
|
26
|
+
from sshfc.core.parser import InvalidCommandError
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from collections.abc import Mapping, Sequence
|
|
30
|
+
|
|
31
|
+
# Shown to the SSH user when the policy can't be loaded; the details go to the audit log only.
|
|
32
|
+
_CONFIG_ERROR_MSG = "SSH command rejected: sshfc is misconfigured; contact the administrator"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_args(args: Sequence[str] | None) -> argparse.Namespace:
|
|
36
|
+
"""Parse sshfc's own arguments."""
|
|
37
|
+
parser = argparse.ArgumentParser(
|
|
38
|
+
prog="sshfc",
|
|
39
|
+
description="Run an SSH user's requested command only if it is in the allowed command set.",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--config",
|
|
43
|
+
type=Path,
|
|
44
|
+
default=DEFAULT_PATH,
|
|
45
|
+
metavar="PATH",
|
|
46
|
+
help=f"Policy file, or directory of *.toml policy files (default: {DEFAULT_PATH}).",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--dry-run", action="store_true", help="Print the command that would run instead of running it."
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"-c",
|
|
53
|
+
dest="command",
|
|
54
|
+
metavar="COMMAND",
|
|
55
|
+
help="Validate COMMAND instead of $SSH_ORIGINAL_COMMAND (for local testing). Quote it as one string.",
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--key-id",
|
|
59
|
+
metavar="LABEL",
|
|
60
|
+
help="Label included in audit records, e.g. to tell apart keys that share an account.",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--insecure-config",
|
|
64
|
+
action="store_true",
|
|
65
|
+
help="Skip the policy ownership checks (only with --dry-run or --check, e.g. to try a policy in a checkout).",
|
|
66
|
+
)
|
|
67
|
+
modes = parser.add_mutually_exclusive_group()
|
|
68
|
+
modes.add_argument(
|
|
69
|
+
"--check",
|
|
70
|
+
action="store_true",
|
|
71
|
+
help="Check the policy and run every command's accept/reject examples, then exit.",
|
|
72
|
+
)
|
|
73
|
+
modes.add_argument(
|
|
74
|
+
"--samples",
|
|
75
|
+
nargs="?",
|
|
76
|
+
const="",
|
|
77
|
+
metavar="NAME",
|
|
78
|
+
help="List the sample policy files shipped with sshfc, or print one, then exit.",
|
|
79
|
+
)
|
|
80
|
+
options = parser.parse_args(args)
|
|
81
|
+
if options.insecure_config and not (options.dry_run or options.check):
|
|
82
|
+
parser.error("--insecure-config requires --dry-run or --check")
|
|
83
|
+
return options
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main(args: Sequence[str] | None = None, environ: Mapping[str, str] | None = None) -> None:
|
|
87
|
+
"""Validate the requested command, then run it (or, with --dry-run, show what would run)."""
|
|
88
|
+
options = _parse_args(args)
|
|
89
|
+
if options.samples is not None:
|
|
90
|
+
raise SystemExit(_samples(options.samples))
|
|
91
|
+
if options.check:
|
|
92
|
+
raise SystemExit(_check(options.config, check_owners=not options.insecure_config))
|
|
93
|
+
|
|
94
|
+
environ = os.environ if environ is None else environ
|
|
95
|
+
audit = AuditLog(environ, key_id=options.key_id)
|
|
96
|
+
|
|
97
|
+
# No command at all (e.g. an interactive login) is rejected like any other invalid command, listing the choices.
|
|
98
|
+
if options.command is not None:
|
|
99
|
+
source, cmd_str = "-c", options.command
|
|
100
|
+
elif "SSH_ORIGINAL_COMMAND" in environ:
|
|
101
|
+
source, cmd_str = "SSH_ORIGINAL_COMMAND", environ["SSH_ORIGINAL_COMMAND"]
|
|
102
|
+
else:
|
|
103
|
+
source, cmd_str = "none", ""
|
|
104
|
+
|
|
105
|
+
# Fail closed: if the policy can't be loaded, reject everything.
|
|
106
|
+
try:
|
|
107
|
+
policy = load_policy(options.config, check_owners=not options.insecure_config)
|
|
108
|
+
except ConfigError as e:
|
|
109
|
+
audit.config_error(source=source, command=cmd_str, error=str(e), dry_run=options.dry_run)
|
|
110
|
+
raise SystemExit(_CONFIG_ERROR_MSG) from e
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
argv, command = policy.root.parse_command(cmd_str)
|
|
114
|
+
except InvalidCommandError as e:
|
|
115
|
+
audit.rejected(source=source, command=cmd_str, reason=str(e), dry_run=options.dry_run)
|
|
116
|
+
raise SystemExit(str(e)) from e
|
|
117
|
+
|
|
118
|
+
audit.accepted(source=source, command=cmd_str, argv=argv, policy=command.source, dry_run=options.dry_run)
|
|
119
|
+
if options.dry_run:
|
|
120
|
+
print(f"Would run: {shlex.join(argv)}")
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
start = time.monotonic()
|
|
124
|
+
status = execute(argv)
|
|
125
|
+
audit.exited(argv=argv, status=status, duration=time.monotonic() - start)
|
|
126
|
+
raise SystemExit(status)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _check(path: Path, *, check_owners: bool) -> int:
|
|
130
|
+
"""Check a policy, print the results, and return an exit status."""
|
|
131
|
+
result = check_policy(path, check_owners=check_owners)
|
|
132
|
+
for warning in result.warnings:
|
|
133
|
+
print(f"warning: {warning}", file=sys.stderr)
|
|
134
|
+
for error in result.errors:
|
|
135
|
+
print(f"error: {error}", file=sys.stderr)
|
|
136
|
+
if result.errors:
|
|
137
|
+
return 1
|
|
138
|
+
assert result.policy is not None # noqa: S101 -- no errors means the policy loaded.
|
|
139
|
+
names = ", ".join(f.name for f in result.policy.files)
|
|
140
|
+
print(f"OK: {path} ({len(result.policy.files)} file(s): {names})")
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _samples(name: str) -> int:
|
|
145
|
+
"""List the sample policy files, or print one; return an exit status."""
|
|
146
|
+
samples = resources.files("sshfc") / "samples"
|
|
147
|
+
files = sorted((f for f in samples.iterdir() if f.name.endswith(".toml")), key=lambda f: f.name)
|
|
148
|
+
if not name:
|
|
149
|
+
print(f"Sample policy files in {samples}:")
|
|
150
|
+
for file in files:
|
|
151
|
+
first_line = file.read_text().partition("\n")[0].removeprefix("#").strip()
|
|
152
|
+
print(f" {file.name:24} {first_line}")
|
|
153
|
+
print("\nCopy or symlink the ones you want into your policy directory, then run: sshfc --check")
|
|
154
|
+
return 0
|
|
155
|
+
for file in files:
|
|
156
|
+
if name in {file.name, file.name.removesuffix(".toml")}:
|
|
157
|
+
print(file.read_text(), end="")
|
|
158
|
+
return 0
|
|
159
|
+
print(f"sshfc: no sample named {name!r}; run 'sshfc --samples' to list them", file=sys.stderr)
|
|
160
|
+
return 1
|
sshfc/core/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Copyright (C) 2026 Stephen T. Jibson.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""sshfc infrastructure.
|
|
5
|
+
|
|
6
|
+
Policies are normally TOML files (see ``sshfc.core.config``); this package's top level is also a Python API for
|
|
7
|
+
building and checking command specs directly.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from sshfc.core.config import ConfigError, Policy, load_policy
|
|
11
|
+
from sshfc.core.parser import CommandRoot, InvalidCommandError, SpecError
|
|
12
|
+
from sshfc.core.spec import Command, Flag, Option, Positional, Validator
|
|
13
|
+
from sshfc.core.validators import CONTAINER_NAME, GoTemplateFormat, Pattern
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"CONTAINER_NAME",
|
|
17
|
+
"Command",
|
|
18
|
+
"CommandRoot",
|
|
19
|
+
"ConfigError",
|
|
20
|
+
"Flag",
|
|
21
|
+
"GoTemplateFormat",
|
|
22
|
+
"InvalidCommandError",
|
|
23
|
+
"Option",
|
|
24
|
+
"Pattern",
|
|
25
|
+
"Policy",
|
|
26
|
+
"Positional",
|
|
27
|
+
"SpecError",
|
|
28
|
+
"Validator",
|
|
29
|
+
"load_policy",
|
|
30
|
+
]
|
sshfc/core/audit.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Copyright (C) 2026 Stephen T. Jibson.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Audit log of accepted and rejected commands.
|
|
5
|
+
|
|
6
|
+
Each record is one JSON line sent to syslog (facility authpriv where available, else auth; ident ``sshfc``). The
|
|
7
|
+
command string is SSH-user input, so JSON encoding with ASCII escaping is also what prevents log injection: control
|
|
8
|
+
characters and newlines can't forge or split records.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import pwd
|
|
16
|
+
import syslog
|
|
17
|
+
from typing import TYPE_CHECKING, Any, Final
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
21
|
+
|
|
22
|
+
#: Receives (syslog priority, message).
|
|
23
|
+
type Emitter = Callable[[int, str], None]
|
|
24
|
+
|
|
25
|
+
_FACILITY: Final = getattr(syslog, "LOG_AUTHPRIV", syslog.LOG_AUTH)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _syslog_emit(priority: int, message: str) -> None:
|
|
29
|
+
"""Send a record to syslog."""
|
|
30
|
+
syslog.openlog("sshfc", syslog.LOG_PID, _FACILITY)
|
|
31
|
+
syslog.syslog(priority, message)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AuditLog:
|
|
35
|
+
"""Writes audit records for one sshfc invocation."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, environ: Mapping[str, str], key_id: str | None = None, emit: Emitter | None = None) -> None:
|
|
38
|
+
"""Initialize with the context common to every record of this invocation."""
|
|
39
|
+
self._emit = _syslog_emit if emit is None else emit
|
|
40
|
+
client = environ.get("SSH_CONNECTION", "").split() # "client_ip client_port server_ip server_port"
|
|
41
|
+
self._context: dict[str, Any] = {
|
|
42
|
+
"user": pwd.getpwuid(os.getuid()).pw_name,
|
|
43
|
+
"key_id": key_id,
|
|
44
|
+
"client": " ".join(client[:2]) or None,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
def rejected(self, *, source: str, command: str, reason: str, dry_run: bool) -> None:
|
|
48
|
+
"""Record a rejected command."""
|
|
49
|
+
self._log(syslog.LOG_WARNING, "rejected", source=source, command=command, reason=reason, dry_run=dry_run)
|
|
50
|
+
|
|
51
|
+
def accepted(self, *, source: str, command: str, argv: Sequence[str], policy: str | None, dry_run: bool) -> None:
|
|
52
|
+
"""Record an accepted command (before it runs, so there's a record even if it never finishes).
|
|
53
|
+
|
|
54
|
+
``policy`` names the policy file(s) that define the matched command.
|
|
55
|
+
"""
|
|
56
|
+
self._log(
|
|
57
|
+
syslog.LOG_INFO,
|
|
58
|
+
"accepted",
|
|
59
|
+
source=source,
|
|
60
|
+
command=command,
|
|
61
|
+
argv=list(argv),
|
|
62
|
+
policy=policy,
|
|
63
|
+
dry_run=dry_run,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def config_error(self, *, source: str, command: str, error: str, dry_run: bool) -> None:
|
|
67
|
+
"""Record that the policy couldn't be loaded, so the command was rejected without being checked."""
|
|
68
|
+
self._log(syslog.LOG_ERR, "config_error", source=source, command=command, error=error, dry_run=dry_run)
|
|
69
|
+
|
|
70
|
+
def exited(self, *, argv: Sequence[str], status: int, duration: float) -> None:
|
|
71
|
+
"""Record an executed command's exit status and how long it ran."""
|
|
72
|
+
self._log(syslog.LOG_INFO, "exited", argv=list(argv), status=status, duration_s=round(duration, 3))
|
|
73
|
+
|
|
74
|
+
def _log(self, priority: int, event: str, **fields: Any) -> None: # noqa: ANN401
|
|
75
|
+
"""Emit one record."""
|
|
76
|
+
record = {"event": event, **self._context, **fields}
|
|
77
|
+
self._emit(priority, json.dumps(record, ensure_ascii=True, separators=(",", ":")))
|
sshfc/core/check.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Copyright (C) 2026 Stephen T. Jibson.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Check a policy: load it and run every command's accept/reject examples against the merged result."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import shlex
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from sshfc.core.config import ConfigError, check_ownership, load_policy, policy_files
|
|
13
|
+
from sshfc.core.parser import InvalidCommandError
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Iterator, Sequence
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from sshfc.core.config import Policy
|
|
20
|
+
from sshfc.core.spec import Command
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class CheckResult:
|
|
25
|
+
"""Problems found by check_policy()."""
|
|
26
|
+
|
|
27
|
+
errors: list[str] = field(default_factory=list)
|
|
28
|
+
warnings: list[str] = field(default_factory=list)
|
|
29
|
+
policy: Policy | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def check_policy(path: Path, *, check_owners: bool = True) -> CheckResult:
|
|
33
|
+
"""Check the policy at path, collecting every problem rather than stopping at the first."""
|
|
34
|
+
result = CheckResult()
|
|
35
|
+
try:
|
|
36
|
+
files = policy_files(path)
|
|
37
|
+
except ConfigError as e:
|
|
38
|
+
result.errors.append(str(e))
|
|
39
|
+
return result
|
|
40
|
+
if check_owners:
|
|
41
|
+
for checked in [path, *files]:
|
|
42
|
+
try:
|
|
43
|
+
check_ownership(checked)
|
|
44
|
+
except ConfigError as e:
|
|
45
|
+
result.errors.append(str(e))
|
|
46
|
+
try:
|
|
47
|
+
result.policy = load_policy(path, check_owners=False)
|
|
48
|
+
except ConfigError as e:
|
|
49
|
+
result.errors.append(str(e))
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
if not result.policy.commands:
|
|
53
|
+
result.warnings.append(f"{path}: no commands defined; every command will be rejected")
|
|
54
|
+
result.errors.extend(run_examples(result.policy))
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run_examples(policy: Policy) -> list[str]:
|
|
59
|
+
"""Run every accept/reject example against the whole policy; return a message for each failure."""
|
|
60
|
+
failures: list[str] = []
|
|
61
|
+
for where, command in _walk(policy.commands):
|
|
62
|
+
top = where.split(" ", 1)[0]
|
|
63
|
+
prefix = f"{command.source}: commands.{where!r}"
|
|
64
|
+
for example in command.accept:
|
|
65
|
+
cmd_str, expected = example if isinstance(example, tuple) else (example, None)
|
|
66
|
+
if not _starts_with(cmd_str, top):
|
|
67
|
+
failures.append(f"{prefix}: accept example {cmd_str!r} must be a full command line from {top!r}")
|
|
68
|
+
continue
|
|
69
|
+
try:
|
|
70
|
+
argv = policy.root.parse(cmd_str)
|
|
71
|
+
except InvalidCommandError as e:
|
|
72
|
+
failures.append(f"{prefix}: accept example {cmd_str!r} was rejected: {e}")
|
|
73
|
+
continue
|
|
74
|
+
if expected is not None and shlex.join(argv) != expected:
|
|
75
|
+
failures.append(
|
|
76
|
+
f"{prefix}: accept example {cmd_str!r} would run {shlex.join(argv)!r}, expected {expected!r}"
|
|
77
|
+
)
|
|
78
|
+
for cmd_str in command.reject:
|
|
79
|
+
if not _starts_with(cmd_str, top):
|
|
80
|
+
failures.append(f"{prefix}: reject example {cmd_str!r} must be a full command line from {top!r}")
|
|
81
|
+
continue
|
|
82
|
+
try:
|
|
83
|
+
argv = policy.root.parse(cmd_str)
|
|
84
|
+
except InvalidCommandError:
|
|
85
|
+
continue
|
|
86
|
+
failures.append(f"{prefix}: reject example {cmd_str!r} was accepted (would run {shlex.join(argv)!r})")
|
|
87
|
+
return failures
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _starts_with(cmd_str: str, top: str) -> bool:
|
|
91
|
+
"""Return whether a command line starts with the given top-level command name."""
|
|
92
|
+
try:
|
|
93
|
+
words = shlex.split(cmd_str)
|
|
94
|
+
except ValueError:
|
|
95
|
+
return cmd_str.split(" ", 1)[0] == top
|
|
96
|
+
return bool(words) and words[0] == top
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _walk(commands: Sequence[Command], path: str = "") -> Iterator[tuple[str, Command]]:
|
|
100
|
+
"""Yield (path, command) for every command in the tree."""
|
|
101
|
+
for command in commands:
|
|
102
|
+
here = f"{path} {command.name}".strip()
|
|
103
|
+
yield here, command
|
|
104
|
+
yield from _walk(command.subcommands, here)
|