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 ADDED
@@ -0,0 +1,4 @@
1
+ # Copyright (C) 2026 Stephen T. Jibson.
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """SSH Force Command validator."""
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)