Security: DaRacci/ssh-guard

Security

docs/SECURITY.md

Security Guide

This document covers the security considerations that matter when deploying and running ssh-guard.


The command= / ForceCommand wrapper is the enforcement point

ssh-guard only runs when OpenSSH actually invokes it. The security boundary is the command= directive or ForceCommand. If a user can reach a shell or another program that bypasses the guard, the restriction is meaningless. Apply command= to every key that should be restricted, or scope ForceCommand with a Match block

Restrict the SSH session, not just the command

A restricted command guard is not a full sandbox. Even with the guard in place, harden the session via traditional SSH hardening techniques:

  • PermitTTY no: prevents interactive shells.
  • Disable agent, TCP, and X11 forwarding.
  • Consider no-port-forwarding, no-agent-forwarding, no-X11-forwarding in authorized_keys options.
  • Give restricted users a dedicated group.

Note on login shells: OpenSSH invokes ForceCommand/command= via the user's login shell with the -c option. The login shell must therefore be able to run commands. Do not set the login shell to nologin or /bin/false for a guarded user, or the guard itself will fail to start.

The config file must be protected

The config defines what commands are allowed. Anyone who can edit it can grant themselves arbitrary commands. Protect it with root ownership and restrictive permissions:

sudo chown root:root /etc/ssh-guard/config.toml
sudo chmod 0644 /etc/ssh-guard/config.toml

Note: the guard runs as the SSH user, not as root, so the config must be readable by that user. 0644 is world-readable but still root-owned and only root-writable, the security boundary is integrity, not confidentiality. The only thing world-read exposes is the allowlist policy, which is low-value since an SSH user can already probe what is allowed.

The audit log must be append-only and protected

The audit log is the record of who ran what. If a user can freely edit it, they can erase their tracks. The guard runs as the SSH user, so the log must be writable by any user but it should be append-only writable and not readable.

sudo touch /var/log/ssh-guard-audit.log
sudo chown root:root /var/log/ssh-guard-audit.log
sudo chmod 0222 /var/log/ssh-guard-audit.log # make it world writable
sudo chattr +a /var/log/ssh-guard-audit.log # set append-only attribute

Why 0222: the guard opens the log with append(true) and only ever appends, so all users need write access but not read access. 0222 grants write to everyone with no read bits, keeping the log unreadable by the audited users. root can always read it regardless of the permission bits.

Why +a: the append-only attribute protects integrity. Existing entries can't be modified, truncated, or deleted, and the file can't be renamed or removed.

Profile selection trusts USER

ssh-guard picks the effective profile from the USER environment variable. Under a normal OpenSSH ForceCommand/command= session, OpenSSH sanitizes the environment and sets USER to the authenticated login name, so this is safe. However if the guard is ever invoked outside such a session from a shell, cron, or any process where the caller controls the environment, the caller can set USER to match a privileged profile and inherit its rules.

Keep the guard reachable only through OpenSSH's forced-command path, and do not run it from attacker-influenced contexts.

Audit write failures are silent

ssh-guard appends audit events but does not fail the command if the write fails. If the audit log becomes unwritable, has incorrect permissions, the disk is full, or other fs issues, the command still runs and the event is silently dropped. An attacker who can make the log unwritable could evade auditing.

Monitor the audit log's writability and disk space, and treat a missing or stale log as an incident rather than an inconvenience.

Note: This is a known limitation and is planned to be addressed.

Use absolute binary paths and verify them

run actions execute a fixed binary path. The use of absolute paths is required (e.g. /usr/bin/systemctl, not systemctl) so a compromised PATH cannot redirect execution. The validate subcommand checks that the binary exists, and with implicit_symlinks enabled, symlinked binaries are canonicalized to their real path before execution, which prevents symlink-swap attacks on the binary. If you set implicit_symlinks = false, validate will raise an error due to the symlinked binaries.

File actions are confined to roots

The actions read_file, tail_file, stat_path, and list_dir resolve the target path and reject it unless it is within one of the configured roots. Keep roots as narrow as possible. Do not add broad roots like / unless you intend to expose the whole filesystem.

Timeouts prevent runaway commands

run actions have a timeout field. On timeout, ssh-guard kills the entire process group and all children with SIGKILL. Set reasonable timeouts per rule so an allowed command cannot hang indefinitely.

Attempts are audited

Every attempt that reaches rule matching is logged to the audit log before any command is executed. An allowed command produces a started event (before execution) and an allowed event (after it completes); a denied command produces a denied event with the rejection reason.

Note: a command that fails to parse (e.g. unbalanced quotes) or a missing SSH_ORIGINAL_COMMAND returns an error without writing an audit event.

Review the audit log regularly, a spike in denials can indicate probing or a misconfigured rule.

add-rule rewrites the config

The add-rule subcommand reads, merges, and writes the config file back. It is a convenience for scaffolding, not a security boundary, you should always review the resulting TOML.

Note:add-rule does not validate the config after writing, a misconfigured rule can break the guard. Always Run validate after any add-rule operation.

Profiles: a user must match at most one

If a user matches multiple profiles, resolve_for_user returns an error and the command is denied. The validate subcommand catches duplicate users across profiles. Keep profile membership unambiguous.

The guard is not a sandbox

ssh-guard restricts which commands run; it does not sandbox what those commands can do. An allowed command like systemctl or journalctl runs with the privileges of the SSH user. Grant only the commands the user genuinely needs, and prefer the least-privileged account possible.

Do not guard the root user

ssh-guard is designed for dedicated, least-privilege accounts, not for root. Applying the guard to root is a bad idea for several reasons:

  • Root is a single point of failure. If the guard misbehaves, the config is wrong, or the binary is broken, you can lock yourself out of the only account that can fix it. A dedicated restricted user can be recreated or bypassed without losing administrative access.
  • Least privilege is undermined. The whole point of the guard is to limit what a compromised session can do. Any command allowed to run as root runs with full system privileges, so a single overly-broad rule becomes a root-equivalent escape hatch.

Instead, create dedicated users, give each only the commands it needs. If you must allow some privileged operations, scope them to a dedicated account with the narrowest possible rule set.

If you update the binary or change the config, re-run validate and re-test an allowed and a denied command. A stale config can silently allow or deny the wrong things.

There aren't any published security advisories

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Security: DaRacci/ssh-guard

Security

docs/SECURITY.md

Security Guide

This document covers the security considerations that matter when deploying and running ssh-guard.


The command= / ForceCommand wrapper is the enforcement point

ssh-guard only runs when OpenSSH actually invokes it. The security boundary is the command= directive or ForceCommand. If a user can reach a shell or another program that bypasses the guard, the restriction is meaningless. Apply command= to every key that should be restricted, or scope ForceCommand with a Match block

Restrict the SSH session, not just the command

A restricted command guard is not a full sandbox. Even with the guard in place, harden the session via traditional SSH hardening techniques:

  • PermitTTY no: prevents interactive shells.
  • Disable agent, TCP, and X11 forwarding.
  • Consider no-port-forwarding, no-agent-forwarding, no-X11-forwarding in authorized_keys options.
  • Give restricted users a dedicated group.

Note on login shells: OpenSSH invokes ForceCommand/command= via the user's login shell with the -c option. The login shell must therefore be able to run commands. Do not set the login shell to nologin or /bin/false for a guarded user, or the guard itself will fail to start.

The config file must be protected

The config defines what commands are allowed. Anyone who can edit it can grant themselves arbitrary commands. Protect it with root ownership and restrictive permissions:

sudo chown root:root /etc/ssh-guard/config.toml
sudo chmod 0644 /etc/ssh-guard/config.toml

Note: the guard runs as the SSH user, not as root, so the config must be readable by that user. 0644 is world-readable but still root-owned and only root-writable, the security boundary is integrity, not confidentiality. The only thing world-read exposes is the allowlist policy, which is low-value since an SSH user can already probe what is allowed.

The audit log must be append-only and protected

The audit log is the record of who ran what. If a user can freely edit it, they can erase their tracks. The guard runs as the SSH user, so the log must be writable by any user but it should be append-only writable and not readable.

sudo touch /var/log/ssh-guard-audit.log
sudo chown root:root /var/log/ssh-guard-audit.log
sudo chmod 0222 /var/log/ssh-guard-audit.log # make it world writable
sudo chattr +a /var/log/ssh-guard-audit.log # set append-only attribute

Why 0222: the guard opens the log with append(true) and only ever appends, so all users need write access but not read access. 0222 grants write to everyone with no read bits, keeping the log unreadable by the audited users. root can always read it regardless of the permission bits.

Why +a: the append-only attribute protects integrity. Existing entries can't be modified, truncated, or deleted, and the file can't be renamed or removed.

Profile selection trusts USER

ssh-guard picks the effective profile from the USER environment variable. Under a normal OpenSSH ForceCommand/command= session, OpenSSH sanitizes the environment and sets USER to the authenticated login name, so this is safe. However if the guard is ever invoked outside such a session from a shell, cron, or any process where the caller controls the environment, the caller can set USER to match a privileged profile and inherit its rules.

Keep the guard reachable only through OpenSSH's forced-command path, and do not run it from attacker-influenced contexts.

Audit write failures are silent

ssh-guard appends audit events but does not fail the command if the write fails. If the audit log becomes unwritable, has incorrect permissions, the disk is full, or other fs issues, the command still runs and the event is silently dropped. An attacker who can make the log unwritable could evade auditing.

Monitor the audit log's writability and disk space, and treat a missing or stale log as an incident rather than an inconvenience.

Note: This is a known limitation and is planned to be addressed.

Use absolute binary paths and verify them

run actions execute a fixed binary path. The use of absolute paths is required (e.g. /usr/bin/systemctl, not systemctl) so a compromised PATH cannot redirect execution. The validate subcommand checks that the binary exists, and with implicit_symlinks enabled, symlinked binaries are canonicalized to their real path before execution, which prevents symlink-swap attacks on the binary. If you set implicit_symlinks = false, validate will raise an error due to the symlinked binaries.

File actions are confined to roots

The actions read_file, tail_file, stat_path, and list_dir resolve the target path and reject it unless it is within one of the configured roots. Keep roots as narrow as possible. Do not add broad roots like / unless you intend to expose the whole filesystem.

Timeouts prevent runaway commands

run actions have a timeout field. On timeout, ssh-guard kills the entire process group and all children with SIGKILL. Set reasonable timeouts per rule so an allowed command cannot hang indefinitely.

Attempts are audited

Every attempt that reaches rule matching is logged to the audit log before any command is executed. An allowed command produces a started event (before execution) and an allowed event (after it completes); a denied command produces a denied event with the rejection reason.

Note: a command that fails to parse (e.g. unbalanced quotes) or a missing SSH_ORIGINAL_COMMAND returns an error without writing an audit event.

Review the audit log regularly, a spike in denials can indicate probing or a misconfigured rule.

add-rule rewrites the config

The add-rule subcommand reads, merges, and writes the config file back. It is a convenience for scaffolding, not a security boundary, you should always review the resulting TOML.

Note:add-rule does not validate the config after writing, a misconfigured rule can break the guard. Always Run validate after any add-rule operation.

Profiles: a user must match at most one

If a user matches multiple profiles, resolve_for_user returns an error and the command is denied. The validate subcommand catches duplicate users across profiles. Keep profile membership unambiguous.

The guard is not a sandbox

ssh-guard restricts which commands run; it does not sandbox what those commands can do. An allowed command like systemctl or journalctl runs with the privileges of the SSH user. Grant only the commands the user genuinely needs, and prefer the least-privileged account possible.

Do not guard the root user

ssh-guard is designed for dedicated, least-privilege accounts, not for root. Applying the guard to root is a bad idea for several reasons:

  • Root is a single point of failure. If the guard misbehaves, the config is wrong, or the binary is broken, you can lock yourself out of the only account that can fix it. A dedicated restricted user can be recreated or bypassed without losing administrative access.
  • Least privilege is undermined. The whole point of the guard is to limit what a compromised session can do. Any command allowed to run as root runs with full system privileges, so a single overly-broad rule becomes a root-equivalent escape hatch.

Instead, create dedicated users, give each only the commands it needs. If you must allow some privileged operations, scope them to a dedicated account with the narrowest possible rule set.

If you update the binary or change the config, re-run validate and re-test an allowed and a denied command. A stale config can silently allow or deny the wrong things.

There aren't any published security advisories

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Security: DaRacci/ssh-guard

Security

docs/SECURITY.md

Security Guide

This document covers the security considerations that matter when deploying and running ssh-guard.


The command= / ForceCommand wrapper is the enforcement point

ssh-guard only runs when OpenSSH actually invokes it. The security boundary is the command= directive or ForceCommand. If a user can reach a shell or another program that bypasses the guard, the restriction is meaningless. Apply command= to every key that should be restricted, or scope ForceCommand with a Match block

Restrict the SSH session, not just the command

A restricted command guard is not a full sandbox. Even with the guard in place, harden the session via traditional SSH hardening techniques:

  • PermitTTY no: prevents interactive shells.
  • Disable agent, TCP, and X11 forwarding.
  • Consider no-port-forwarding, no-agent-forwarding, no-X11-forwarding in authorized_keys options.
  • Give restricted users a dedicated group.

Note on login shells: OpenSSH invokes ForceCommand/command= via the user's login shell with the -c option. The login shell must therefore be able to run commands. Do not set the login shell to nologin or /bin/false for a guarded user, or the guard itself will fail to start.

The config file must be protected

The config defines what commands are allowed. Anyone who can edit it can grant themselves arbitrary commands. Protect it with root ownership and restrictive permissions:

sudo chown root:root /etc/ssh-guard/config.toml
sudo chmod 0644 /etc/ssh-guard/config.toml

Note: the guard runs as the SSH user, not as root, so the config must be readable by that user. 0644 is world-readable but still root-owned and only root-writable, the security boundary is integrity, not confidentiality. The only thing world-read exposes is the allowlist policy, which is low-value since an SSH user can already probe what is allowed.

The audit log must be append-only and protected

The audit log is the record of who ran what. If a user can freely edit it, they can erase their tracks. The guard runs as the SSH user, so the log must be writable by any user but it should be append-only writable and not readable.

sudo touch /var/log/ssh-guard-audit.log
sudo chown root:root /var/log/ssh-guard-audit.log
sudo chmod 0222 /var/log/ssh-guard-audit.log # make it world writable
sudo chattr +a /var/log/ssh-guard-audit.log # set append-only attribute

Why 0222: the guard opens the log with append(true) and only ever appends, so all users need write access but not read access. 0222 grants write to everyone with no read bits, keeping the log unreadable by the audited users. root can always read it regardless of the permission bits.

Why +a: the append-only attribute protects integrity. Existing entries can't be modified, truncated, or deleted, and the file can't be renamed or removed.

Profile selection trusts USER

ssh-guard picks the effective profile from the USER environment variable. Under a normal OpenSSH ForceCommand/command= session, OpenSSH sanitizes the environment and sets USER to the authenticated login name, so this is safe. However if the guard is ever invoked outside such a session from a shell, cron, or any process where the caller controls the environment, the caller can set USER to match a privileged profile and inherit its rules.

Keep the guard reachable only through OpenSSH's forced-command path, and do not run it from attacker-influenced contexts.

Audit write failures are silent

ssh-guard appends audit events but does not fail the command if the write fails. If the audit log becomes unwritable, has incorrect permissions, the disk is full, or other fs issues, the command still runs and the event is silently dropped. An attacker who can make the log unwritable could evade auditing.

Monitor the audit log's writability and disk space, and treat a missing or stale log as an incident rather than an inconvenience.

Note: This is a known limitation and is planned to be addressed.

Use absolute binary paths and verify them

run actions execute a fixed binary path. The use of absolute paths is required (e.g. /usr/bin/systemctl, not systemctl) so a compromised PATH cannot redirect execution. The validate subcommand checks that the binary exists, and with implicit_symlinks enabled, symlinked binaries are canonicalized to their real path before execution, which prevents symlink-swap attacks on the binary. If you set implicit_symlinks = false, validate will raise an error due to the symlinked binaries.

File actions are confined to roots

The actions read_file, tail_file, stat_path, and list_dir resolve the target path and reject it unless it is within one of the configured roots. Keep roots as narrow as possible. Do not add broad roots like / unless you intend to expose the whole filesystem.

Timeouts prevent runaway commands

run actions have a timeout field. On timeout, ssh-guard kills the entire process group and all children with SIGKILL. Set reasonable timeouts per rule so an allowed command cannot hang indefinitely.

Attempts are audited

Every attempt that reaches rule matching is logged to the audit log before any command is executed. An allowed command produces a started event (before execution) and an allowed event (after it completes); a denied command produces a denied event with the rejection reason.

Note: a command that fails to parse (e.g. unbalanced quotes) or a missing SSH_ORIGINAL_COMMAND returns an error without writing an audit event.

Review the audit log regularly, a spike in denials can indicate probing or a misconfigured rule.

add-rule rewrites the config

The add-rule subcommand reads, merges, and writes the config file back. It is a convenience for scaffolding, not a security boundary, you should always review the resulting TOML.

Note:add-rule does not validate the config after writing, a misconfigured rule can break the guard. Always Run validate after any add-rule operation.

Profiles: a user must match at most one

If a user matches multiple profiles, resolve_for_user returns an error and the command is denied. The validate subcommand catches duplicate users across profiles. Keep profile membership unambiguous.

The guard is not a sandbox

ssh-guard restricts which commands run; it does not sandbox what those commands can do. An allowed command like systemctl or journalctl runs with the privileges of the SSH user. Grant only the commands the user genuinely needs, and prefer the least-privileged account possible.

Do not guard the root user

ssh-guard is designed for dedicated, least-privilege accounts, not for root. Applying the guard to root is a bad idea for several reasons:

  • Root is a single point of failure. If the guard misbehaves, the config is wrong, or the binary is broken, you can lock yourself out of the only account that can fix it. A dedicated restricted user can be recreated or bypassed without losing administrative access.
  • Least privilege is undermined. The whole point of the guard is to limit what a compromised session can do. Any command allowed to run as root runs with full system privileges, so a single overly-broad rule becomes a root-equivalent escape hatch.

Instead, create dedicated users, give each only the commands it needs. If you must allow some privileged operations, scope them to a dedicated account with the narrowest possible rule set.

If you update the binary or change the config, re-run validate and re-test an allowed and a denied command. A stale config can silently allow or deny the wrong things.

There aren't any published security advisories

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Security: DaRacci/ssh-guard

Security

docs/SECURITY.md

Security Guide

This document covers the security considerations that matter when deploying and running ssh-guard.


The command= / ForceCommand wrapper is the enforcement point

ssh-guard only runs when OpenSSH actually invokes it. The security boundary is the command= directive or ForceCommand. If a user can reach a shell or another program that bypasses the guard, the restriction is meaningless. Apply command= to every key that should be restricted, or scope ForceCommand with a Match block

Restrict the SSH session, not just the command

A restricted command guard is not a full sandbox. Even with the guard in place, harden the session via traditional SSH hardening techniques:

  • PermitTTY no: prevents interactive shells.
  • Disable agent, TCP, and X11 forwarding.
  • Consider no-port-forwarding, no-agent-forwarding, no-X11-forwarding in authorized_keys options.
  • Give restricted users a dedicated group.

Note on login shells: OpenSSH invokes ForceCommand/command= via the user's login shell with the -c option. The login shell must therefore be able to run commands. Do not set the login shell to nologin or /bin/false for a guarded user, or the guard itself will fail to start.

The config file must be protected

The config defines what commands are allowed. Anyone who can edit it can grant themselves arbitrary commands. Protect it with root ownership and restrictive permissions:

sudo chown root:root /etc/ssh-guard/config.toml
sudo chmod 0644 /etc/ssh-guard/config.toml

Note: the guard runs as the SSH user, not as root, so the config must be readable by that user. 0644 is world-readable but still root-owned and only root-writable, the security boundary is integrity, not confidentiality. The only thing world-read exposes is the allowlist policy, which is low-value since an SSH user can already probe what is allowed.

The audit log must be append-only and protected

The audit log is the record of who ran what. If a user can freely edit it, they can erase their tracks. The guard runs as the SSH user, so the log must be writable by any user but it should be append-only writable and not readable.

sudo touch /var/log/ssh-guard-audit.log
sudo chown root:root /var/log/ssh-guard-audit.log
sudo chmod 0222 /var/log/ssh-guard-audit.log # make it world writable
sudo chattr +a /var/log/ssh-guard-audit.log # set append-only attribute

Why 0222: the guard opens the log with append(true) and only ever appends, so all users need write access but not read access. 0222 grants write to everyone with no read bits, keeping the log unreadable by the audited users. root can always read it regardless of the permission bits.

Why +a: the append-only attribute protects integrity. Existing entries can't be modified, truncated, or deleted, and the file can't be renamed or removed.

Profile selection trusts USER

ssh-guard picks the effective profile from the USER environment variable. Under a normal OpenSSH ForceCommand/command= session, OpenSSH sanitizes the environment and sets USER to the authenticated login name, so this is safe. However if the guard is ever invoked outside such a session from a shell, cron, or any process where the caller controls the environment, the caller can set USER to match a privileged profile and inherit its rules.

Keep the guard reachable only through OpenSSH's forced-command path, and do not run it from attacker-influenced contexts.

Audit write failures are silent

ssh-guard appends audit events but does not fail the command if the write fails. If the audit log becomes unwritable, has incorrect permissions, the disk is full, or other fs issues, the command still runs and the event is silently dropped. An attacker who can make the log unwritable could evade auditing.

Monitor the audit log's writability and disk space, and treat a missing or stale log as an incident rather than an inconvenience.

Note: This is a known limitation and is planned to be addressed.

Use absolute binary paths and verify them

run actions execute a fixed binary path. The use of absolute paths is required (e.g. /usr/bin/systemctl, not systemctl) so a compromised PATH cannot redirect execution. The validate subcommand checks that the binary exists, and with implicit_symlinks enabled, symlinked binaries are canonicalized to their real path before execution, which prevents symlink-swap attacks on the binary. If you set implicit_symlinks = false, validate will raise an error due to the symlinked binaries.

File actions are confined to roots

The actions read_file, tail_file, stat_path, and list_dir resolve the target path and reject it unless it is within one of the configured roots. Keep roots as narrow as possible. Do not add broad roots like / unless you intend to expose the whole filesystem.

Timeouts prevent runaway commands

run actions have a timeout field. On timeout, ssh-guard kills the entire process group and all children with SIGKILL. Set reasonable timeouts per rule so an allowed command cannot hang indefinitely.

Attempts are audited

Every attempt that reaches rule matching is logged to the audit log before any command is executed. An allowed command produces a started event (before execution) and an allowed event (after it completes); a denied command produces a denied event with the rejection reason.

Note: a command that fails to parse (e.g. unbalanced quotes) or a missing SSH_ORIGINAL_COMMAND returns an error without writing an audit event.

Review the audit log regularly, a spike in denials can indicate probing or a misconfigured rule.

add-rule rewrites the config

The add-rule subcommand reads, merges, and writes the config file back. It is a convenience for scaffolding, not a security boundary, you should always review the resulting TOML.

Note:add-rule does not validate the config after writing, a misconfigured rule can break the guard. Always Run validate after any add-rule operation.

Profiles: a user must match at most one

If a user matches multiple profiles, resolve_for_user returns an error and the command is denied. The validate subcommand catches duplicate users across profiles. Keep profile membership unambiguous.

The guard is not a sandbox

ssh-guard restricts which commands run; it does not sandbox what those commands can do. An allowed command like systemctl or journalctl runs with the privileges of the SSH user. Grant only the commands the user genuinely needs, and prefer the least-privileged account possible.

Do not guard the root user

ssh-guard is designed for dedicated, least-privilege accounts, not for root. Applying the guard to root is a bad idea for several reasons:

  • Root is a single point of failure. If the guard misbehaves, the config is wrong, or the binary is broken, you can lock yourself out of the only account that can fix it. A dedicated restricted user can be recreated or bypassed without losing administrative access.
  • Least privilege is undermined. The whole point of the guard is to limit what a compromised session can do. Any command allowed to run as root runs with full system privileges, so a single overly-broad rule becomes a root-equivalent escape hatch.

Instead, create dedicated users, give each only the commands it needs. If you must allow some privileged operations, scope them to a dedicated account with the narrowest possible rule set.

If you update the binary or change the config, re-run validate and re-test an allowed and a denied command. A stale config can silently allow or deny the wrong things.

There aren't any published security advisories

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Security: DaRacci/ssh-guard

Security

docs/SECURITY.md

Security Guide

This document covers the security considerations that matter when deploying and running ssh-guard.


The command= / ForceCommand wrapper is the enforcement point

ssh-guard only runs when OpenSSH actually invokes it. The security boundary is the command= directive or ForceCommand. If a user can reach a shell or another program that bypasses the guard, the restriction is meaningless. Apply command= to every key that should be restricted, or scope ForceCommand with a Match block

Restrict the SSH session, not just the command

A restricted command guard is not a full sandbox. Even with the guard in place, harden the session via traditional SSH hardening techniques:

  • PermitTTY no: prevents interactive shells.
  • Disable agent, TCP, and X11 forwarding.
  • Consider no-port-forwarding, no-agent-forwarding, no-X11-forwarding in authorized_keys options.
  • Give restricted users a dedicated group.

Note on login shells: OpenSSH invokes ForceCommand/command= via the user's login shell with the -c option. The login shell must therefore be able to run commands. Do not set the login shell to nologin or /bin/false for a guarded user, or the guard itself will fail to start.

The config file must be protected

The config defines what commands are allowed. Anyone who can edit it can grant themselves arbitrary commands. Protect it with root ownership and restrictive permissions:

sudo chown root:root /etc/ssh-guard/config.toml
sudo chmod 0644 /etc/ssh-guard/config.toml

Note: the guard runs as the SSH user, not as root, so the config must be readable by that user. 0644 is world-readable but still root-owned and only root-writable, the security boundary is integrity, not confidentiality. The only thing world-read exposes is the allowlist policy, which is low-value since an SSH user can already probe what is allowed.

The audit log must be append-only and protected

The audit log is the record of who ran what. If a user can freely edit it, they can erase their tracks. The guard runs as the SSH user, so the log must be writable by any user but it should be append-only writable and not readable.

sudo touch /var/log/ssh-guard-audit.log
sudo chown root:root /var/log/ssh-guard-audit.log
sudo chmod 0222 /var/log/ssh-guard-audit.log # make it world writable
sudo chattr +a /var/log/ssh-guard-audit.log # set append-only attribute

Why 0222: the guard opens the log with append(true) and only ever appends, so all users need write access but not read access. 0222 grants write to everyone with no read bits, keeping the log unreadable by the audited users. root can always read it regardless of the permission bits.

Why +a: the append-only attribute protects integrity. Existing entries can't be modified, truncated, or deleted, and the file can't be renamed or removed.

Profile selection trusts USER

ssh-guard picks the effective profile from the USER environment variable. Under a normal OpenSSH ForceCommand/command= session, OpenSSH sanitizes the environment and sets USER to the authenticated login name, so this is safe. However if the guard is ever invoked outside such a session from a shell, cron, or any process where the caller controls the environment, the caller can set USER to match a privileged profile and inherit its rules.

Keep the guard reachable only through OpenSSH's forced-command path, and do not run it from attacker-influenced contexts.

Audit write failures are silent

ssh-guard appends audit events but does not fail the command if the write fails. If the audit log becomes unwritable, has incorrect permissions, the disk is full, or other fs issues, the command still runs and the event is silently dropped. An attacker who can make the log unwritable could evade auditing.

Monitor the audit log's writability and disk space, and treat a missing or stale log as an incident rather than an inconvenience.

Note: This is a known limitation and is planned to be addressed.

Use absolute binary paths and verify them

run actions execute a fixed binary path. The use of absolute paths is required (e.g. /usr/bin/systemctl, not systemctl) so a compromised PATH cannot redirect execution. The validate subcommand checks that the binary exists, and with implicit_symlinks enabled, symlinked binaries are canonicalized to their real path before execution, which prevents symlink-swap attacks on the binary. If you set implicit_symlinks = false, validate will raise an error due to the symlinked binaries.

File actions are confined to roots

The actions read_file, tail_file, stat_path, and list_dir resolve the target path and reject it unless it is within one of the configured roots. Keep roots as narrow as possible. Do not add broad roots like / unless you intend to expose the whole filesystem.

Timeouts prevent runaway commands

run actions have a timeout field. On timeout, ssh-guard kills the entire process group and all children with SIGKILL. Set reasonable timeouts per rule so an allowed command cannot hang indefinitely.

Attempts are audited

Every attempt that reaches rule matching is logged to the audit log before any command is executed. An allowed command produces a started event (before execution) and an allowed event (after it completes); a denied command produces a denied event with the rejection reason.

Note: a command that fails to parse (e.g. unbalanced quotes) or a missing SSH_ORIGINAL_COMMAND returns an error without writing an audit event.

Review the audit log regularly, a spike in denials can indicate probing or a misconfigured rule.

add-rule rewrites the config

The add-rule subcommand reads, merges, and writes the config file back. It is a convenience for scaffolding, not a security boundary, you should always review the resulting TOML.

Note:add-rule does not validate the config after writing, a misconfigured rule can break the guard. Always Run validate after any add-rule operation.

Profiles: a user must match at most one

If a user matches multiple profiles, resolve_for_user returns an error and the command is denied. The validate subcommand catches duplicate users across profiles. Keep profile membership unambiguous.

The guard is not a sandbox

ssh-guard restricts which commands run; it does not sandbox what those commands can do. An allowed command like systemctl or journalctl runs with the privileges of the SSH user. Grant only the commands the user genuinely needs, and prefer the least-privileged account possible.

Do not guard the root user

ssh-guard is designed for dedicated, least-privilege accounts, not for root. Applying the guard to root is a bad idea for several reasons:

  • Root is a single point of failure. If the guard misbehaves, the config is wrong, or the binary is broken, you can lock yourself out of the only account that can fix it. A dedicated restricted user can be recreated or bypassed without losing administrative access.
  • Least privilege is undermined. The whole point of the guard is to limit what a compromised session can do. Any command allowed to run as root runs with full system privileges, so a single overly-broad rule becomes a root-equivalent escape hatch.

Instead, create dedicated users, give each only the commands it needs. If you must allow some privileged operations, scope them to a dedicated account with the narrowest possible rule set.

If you update the binary or change the config, re-run validate and re-test an allowed and a denied command. A stale config can silently allow or deny the wrong things.

There aren't any published security advisories

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Security: DaRacci/ssh-guard

Security

docs/SECURITY.md

Security Guide

This document covers the security considerations that matter when deploying and running ssh-guard.


The command= / ForceCommand wrapper is the enforcement point

ssh-guard only runs when OpenSSH actually invokes it. The security boundary is the command= directive or ForceCommand. If a user can reach a shell or another program that bypasses the guard, the restriction is meaningless. Apply command= to every key that should be restricted, or scope ForceCommand with a Match block

Restrict the SSH session, not just the command

A restricted command guard is not a full sandbox. Even with the guard in place, harden the session via traditional SSH hardening techniques:

  • PermitTTY no: prevents interactive shells.
  • Disable agent, TCP, and X11 forwarding.
  • Consider no-port-forwarding, no-agent-forwarding, no-X11-forwarding in authorized_keys options.
  • Give restricted users a dedicated group.

Note on login shells: OpenSSH invokes ForceCommand/command= via the user's login shell with the -c option. The login shell must therefore be able to run commands. Do not set the login shell to nologin or /bin/false for a guarded user, or the guard itself will fail to start.

The config file must be protected

The config defines what commands are allowed. Anyone who can edit it can grant themselves arbitrary commands. Protect it with root ownership and restrictive permissions:

sudo chown root:root /etc/ssh-guard/config.toml
sudo chmod 0644 /etc/ssh-guard/config.toml

Note: the guard runs as the SSH user, not as root, so the config must be readable by that user. 0644 is world-readable but still root-owned and only root-writable, the security boundary is integrity, not confidentiality. The only thing world-read exposes is the allowlist policy, which is low-value since an SSH user can already probe what is allowed.

The audit log must be append-only and protected

The audit log is the record of who ran what. If a user can freely edit it, they can erase their tracks. The guard runs as the SSH user, so the log must be writable by any user but it should be append-only writable and not readable.

sudo touch /var/log/ssh-guard-audit.log
sudo chown root:root /var/log/ssh-guard-audit.log
sudo chmod 0222 /var/log/ssh-guard-audit.log # make it world writable
sudo chattr +a /var/log/ssh-guard-audit.log # set append-only attribute

Why 0222: the guard opens the log with append(true) and only ever appends, so all users need write access but not read access. 0222 grants write to everyone with no read bits, keeping the log unreadable by the audited users. root can always read it regardless of the permission bits.

Why +a: the append-only attribute protects integrity. Existing entries can't be modified, truncated, or deleted, and the file can't be renamed or removed.

Profile selection trusts USER

ssh-guard picks the effective profile from the USER environment variable. Under a normal OpenSSH ForceCommand/command= session, OpenSSH sanitizes the environment and sets USER to the authenticated login name, so this is safe. However if the guard is ever invoked outside such a session from a shell, cron, or any process where the caller controls the environment, the caller can set USER to match a privileged profile and inherit its rules.

Keep the guard reachable only through OpenSSH's forced-command path, and do not run it from attacker-influenced contexts.

Audit write failures are silent

ssh-guard appends audit events but does not fail the command if the write fails. If the audit log becomes unwritable, has incorrect permissions, the disk is full, or other fs issues, the command still runs and the event is silently dropped. An attacker who can make the log unwritable could evade auditing.

Monitor the audit log's writability and disk space, and treat a missing or stale log as an incident rather than an inconvenience.

Note: This is a known limitation and is planned to be addressed.

Use absolute binary paths and verify them

run actions execute a fixed binary path. The use of absolute paths is required (e.g. /usr/bin/systemctl, not systemctl) so a compromised PATH cannot redirect execution. The validate subcommand checks that the binary exists, and with implicit_symlinks enabled, symlinked binaries are canonicalized to their real path before execution, which prevents symlink-swap attacks on the binary. If you set implicit_symlinks = false, validate will raise an error due to the symlinked binaries.

File actions are confined to roots

The actions read_file, tail_file, stat_path, and list_dir resolve the target path and reject it unless it is within one of the configured roots. Keep roots as narrow as possible. Do not add broad roots like / unless you intend to expose the whole filesystem.

Timeouts prevent runaway commands

run actions have a timeout field. On timeout, ssh-guard kills the entire process group and all children with SIGKILL. Set reasonable timeouts per rule so an allowed command cannot hang indefinitely.

Attempts are audited

Every attempt that reaches rule matching is logged to the audit log before any command is executed. An allowed command produces a started event (before execution) and an allowed event (after it completes); a denied command produces a denied event with the rejection reason.

Note: a command that fails to parse (e.g. unbalanced quotes) or a missing SSH_ORIGINAL_COMMAND returns an error without writing an audit event.

Review the audit log regularly, a spike in denials can indicate probing or a misconfigured rule.

add-rule rewrites the config

The add-rule subcommand reads, merges, and writes the config file back. It is a convenience for scaffolding, not a security boundary, you should always review the resulting TOML.

Note:add-rule does not validate the config after writing, a misconfigured rule can break the guard. Always Run validate after any add-rule operation.

Profiles: a user must match at most one

If a user matches multiple profiles, resolve_for_user returns an error and the command is denied. The validate subcommand catches duplicate users across profiles. Keep profile membership unambiguous.

The guard is not a sandbox

ssh-guard restricts which commands run; it does not sandbox what those commands can do. An allowed command like systemctl or journalctl runs with the privileges of the SSH user. Grant only the commands the user genuinely needs, and prefer the least-privileged account possible.

Do not guard the root user

ssh-guard is designed for dedicated, least-privilege accounts, not for root. Applying the guard to root is a bad idea for several reasons:

  • Root is a single point of failure. If the guard misbehaves, the config is wrong, or the binary is broken, you can lock yourself out of the only account that can fix it. A dedicated restricted user can be recreated or bypassed without losing administrative access.
  • Least privilege is undermined. The whole point of the guard is to limit what a compromised session can do. Any command allowed to run as root runs with full system privileges, so a single overly-broad rule becomes a root-equivalent escape hatch.

Instead, create dedicated users, give each only the commands it needs. If you must allow some privileged operations, scope them to a dedicated account with the narrowest possible rule set.

If you update the binary or change the config, re-run validate and re-test an allowed and a denied command. A stale config can silently allow or deny the wrong things.

There aren't any published security advisories

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Security: DaRacci/ssh-guard

Security

docs/SECURITY.md

Security Guide

This document covers the security considerations that matter when deploying and running ssh-guard.


The command= / ForceCommand wrapper is the enforcement point

ssh-guard only runs when OpenSSH actually invokes it. The security boundary is the command= directive or ForceCommand. If a user can reach a shell or another program that bypasses the guard, the restriction is meaningless. Apply command= to every key that should be restricted, or scope ForceCommand with a Match block

Restrict the SSH session, not just the command

A restricted command guard is not a full sandbox. Even with the guard in place, harden the session via traditional SSH hardening techniques:

  • PermitTTY no: prevents interactive shells.
  • Disable agent, TCP, and X11 forwarding.
  • Consider no-port-forwarding, no-agent-forwarding, no-X11-forwarding in authorized_keys options.
  • Give restricted users a dedicated group.

Note on login shells: OpenSSH invokes ForceCommand/command= via the user's login shell with the -c option. The login shell must therefore be able to run commands. Do not set the login shell to nologin or /bin/false for a guarded user, or the guard itself will fail to start.

The config file must be protected

The config defines what commands are allowed. Anyone who can edit it can grant themselves arbitrary commands. Protect it with root ownership and restrictive permissions:

sudo chown root:root /etc/ssh-guard/config.toml
sudo chmod 0644 /etc/ssh-guard/config.toml

Note: the guard runs as the SSH user, not as root, so the config must be readable by that user. 0644 is world-readable but still root-owned and only root-writable, the security boundary is integrity, not confidentiality. The only thing world-read exposes is the allowlist policy, which is low-value since an SSH user can already probe what is allowed.

The audit log must be append-only and protected

The audit log is the record of who ran what. If a user can freely edit it, they can erase their tracks. The guard runs as the SSH user, so the log must be writable by any user but it should be append-only writable and not readable.

sudo touch /var/log/ssh-guard-audit.log
sudo chown root:root /var/log/ssh-guard-audit.log
sudo chmod 0222 /var/log/ssh-guard-audit.log # make it world writable
sudo chattr +a /var/log/ssh-guard-audit.log # set append-only attribute

Why 0222: the guard opens the log with append(true) and only ever appends, so all users need write access but not read access. 0222 grants write to everyone with no read bits, keeping the log unreadable by the audited users. root can always read it regardless of the permission bits.

Why +a: the append-only attribute protects integrity. Existing entries can't be modified, truncated, or deleted, and the file can't be renamed or removed.

Profile selection trusts USER

ssh-guard picks the effective profile from the USER environment variable. Under a normal OpenSSH ForceCommand/command= session, OpenSSH sanitizes the environment and sets USER to the authenticated login name, so this is safe. However if the guard is ever invoked outside such a session from a shell, cron, or any process where the caller controls the environment, the caller can set USER to match a privileged profile and inherit its rules.

Keep the guard reachable only through OpenSSH's forced-command path, and do not run it from attacker-influenced contexts.

Audit write failures are silent

ssh-guard appends audit events but does not fail the command if the write fails. If the audit log becomes unwritable, has incorrect permissions, the disk is full, or other fs issues, the command still runs and the event is silently dropped. An attacker who can make the log unwritable could evade auditing.

Monitor the audit log's writability and disk space, and treat a missing or stale log as an incident rather than an inconvenience.

Note: This is a known limitation and is planned to be addressed.

Use absolute binary paths and verify them

run actions execute a fixed binary path. The use of absolute paths is required (e.g. /usr/bin/systemctl, not systemctl) so a compromised PATH cannot redirect execution. The validate subcommand checks that the binary exists, and with implicit_symlinks enabled, symlinked binaries are canonicalized to their real path before execution, which prevents symlink-swap attacks on the binary. If you set implicit_symlinks = false, validate will raise an error due to the symlinked binaries.

File actions are confined to roots

The actions read_file, tail_file, stat_path, and list_dir resolve the target path and reject it unless it is within one of the configured roots. Keep roots as narrow as possible. Do not add broad roots like / unless you intend to expose the whole filesystem.

Timeouts prevent runaway commands

run actions have a timeout field. On timeout, ssh-guard kills the entire process group and all children with SIGKILL. Set reasonable timeouts per rule so an allowed command cannot hang indefinitely.

Attempts are audited

Every attempt that reaches rule matching is logged to the audit log before any command is executed. An allowed command produces a started event (before execution) and an allowed event (after it completes); a denied command produces a denied event with the rejection reason.

Note: a command that fails to parse (e.g. unbalanced quotes) or a missing SSH_ORIGINAL_COMMAND returns an error without writing an audit event.

Review the audit log regularly, a spike in denials can indicate probing or a misconfigured rule.

add-rule rewrites the config

The add-rule subcommand reads, merges, and writes the config file back. It is a convenience for scaffolding, not a security boundary, you should always review the resulting TOML.

Note:add-rule does not validate the config after writing, a misconfigured rule can break the guard. Always Run validate after any add-rule operation.

Profiles: a user must match at most one

If a user matches multiple profiles, resolve_for_user returns an error and the command is denied. The validate subcommand catches duplicate users across profiles. Keep profile membership unambiguous.

The guard is not a sandbox

ssh-guard restricts which commands run; it does not sandbox what those commands can do. An allowed command like systemctl or journalctl runs with the privileges of the SSH user. Grant only the commands the user genuinely needs, and prefer the least-privileged account possible.

Do not guard the root user

ssh-guard is designed for dedicated, least-privilege accounts, not for root. Applying the guard to root is a bad idea for several reasons:

  • Root is a single point of failure. If the guard misbehaves, the config is wrong, or the binary is broken, you can lock yourself out of the only account that can fix it. A dedicated restricted user can be recreated or bypassed without losing administrative access.
  • Least privilege is undermined. The whole point of the guard is to limit what a compromised session can do. Any command allowed to run as root runs with full system privileges, so a single overly-broad rule becomes a root-equivalent escape hatch.

Instead, create dedicated users, give each only the commands it needs. If you must allow some privileged operations, scope them to a dedicated account with the narrowest possible rule set.

If you update the binary or change the config, re-run validate and re-test an allowed and a denied command. A stale config can silently allow or deny the wrong things.

There aren't any published security advisories

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Security: DaRacci/ssh-guard

Security

docs/SECURITY.md

Security Guide

This document covers the security considerations that matter when deploying and running ssh-guard.


The command= / ForceCommand wrapper is the enforcement point

ssh-guard only runs when OpenSSH actually invokes it. The security boundary is the command= directive or ForceCommand. If a user can reach a shell or another program that bypasses the guard, the restriction is meaningless. Apply command= to every key that should be restricted, or scope ForceCommand with a Match block

Restrict the SSH session, not just the command

A restricted command guard is not a full sandbox. Even with the guard in place, harden the session via traditional SSH hardening techniques:

  • PermitTTY no: prevents interactive shells.
  • Disable agent, TCP, and X11 forwarding.
  • Consider no-port-forwarding, no-agent-forwarding, no-X11-forwarding in authorized_keys options.
  • Give restricted users a dedicated group.

Note on login shells: OpenSSH invokes ForceCommand/command= via the user's login shell with the -c option. The login shell must therefore be able to run commands. Do not set the login shell to nologin or /bin/false for a guarded user, or the guard itself will fail to start.

The config file must be protected

The config defines what commands are allowed. Anyone who can edit it can grant themselves arbitrary commands. Protect it with root ownership and restrictive permissions:

sudo chown root:root /etc/ssh-guard/config.toml
sudo chmod 0644 /etc/ssh-guard/config.toml

Note: the guard runs as the SSH user, not as root, so the config must be readable by that user. 0644 is world-readable but still root-owned and only root-writable, the security boundary is integrity, not confidentiality. The only thing world-read exposes is the allowlist policy, which is low-value since an SSH user can already probe what is allowed.

The audit log must be append-only and protected

The audit log is the record of who ran what. If a user can freely edit it, they can erase their tracks. The guard runs as the SSH user, so the log must be writable by any user but it should be append-only writable and not readable.

sudo touch /var/log/ssh-guard-audit.log
sudo chown root:root /var/log/ssh-guard-audit.log
sudo chmod 0222 /var/log/ssh-guard-audit.log # make it world writable
sudo chattr +a /var/log/ssh-guard-audit.log # set append-only attribute

Why 0222: the guard opens the log with append(true) and only ever appends, so all users need write access but not read access. 0222 grants write to everyone with no read bits, keeping the log unreadable by the audited users. root can always read it regardless of the permission bits.

Why +a: the append-only attribute protects integrity. Existing entries can't be modified, truncated, or deleted, and the file can't be renamed or removed.

Profile selection trusts USER

ssh-guard picks the effective profile from the USER environment variable. Under a normal OpenSSH ForceCommand/command= session, OpenSSH sanitizes the environment and sets USER to the authenticated login name, so this is safe. However if the guard is ever invoked outside such a session from a shell, cron, or any process where the caller controls the environment, the caller can set USER to match a privileged profile and inherit its rules.

Keep the guard reachable only through OpenSSH's forced-command path, and do not run it from attacker-influenced contexts.

Audit write failures are silent

ssh-guard appends audit events but does not fail the command if the write fails. If the audit log becomes unwritable, has incorrect permissions, the disk is full, or other fs issues, the command still runs and the event is silently dropped. An attacker who can make the log unwritable could evade auditing.

Monitor the audit log's writability and disk space, and treat a missing or stale log as an incident rather than an inconvenience.

Note: This is a known limitation and is planned to be addressed.

Use absolute binary paths and verify them

run actions execute a fixed binary path. The use of absolute paths is required (e.g. /usr/bin/systemctl, not systemctl) so a compromised PATH cannot redirect execution. The validate subcommand checks that the binary exists, and with implicit_symlinks enabled, symlinked binaries are canonicalized to their real path before execution, which prevents symlink-swap attacks on the binary. If you set implicit_symlinks = false, validate will raise an error due to the symlinked binaries.

File actions are confined to roots

The actions read_file, tail_file, stat_path, and list_dir resolve the target path and reject it unless it is within one of the configured roots. Keep roots as narrow as possible. Do not add broad roots like / unless you intend to expose the whole filesystem.

Timeouts prevent runaway commands

run actions have a timeout field. On timeout, ssh-guard kills the entire process group and all children with SIGKILL. Set reasonable timeouts per rule so an allowed command cannot hang indefinitely.

Attempts are audited

Every attempt that reaches rule matching is logged to the audit log before any command is executed. An allowed command produces a started event (before execution) and an allowed event (after it completes); a denied command produces a denied event with the rejection reason.

Note: a command that fails to parse (e.g. unbalanced quotes) or a missing SSH_ORIGINAL_COMMAND returns an error without writing an audit event.

Review the audit log regularly, a spike in denials can indicate probing or a misconfigured rule.

add-rule rewrites the config

The add-rule subcommand reads, merges, and writes the config file back. It is a convenience for scaffolding, not a security boundary, you should always review the resulting TOML.

Note:add-rule does not validate the config after writing, a misconfigured rule can break the guard. Always Run validate after any add-rule operation.

Profiles: a user must match at most one

If a user matches multiple profiles, resolve_for_user returns an error and the command is denied. The validate subcommand catches duplicate users across profiles. Keep profile membership unambiguous.

The guard is not a sandbox

ssh-guard restricts which commands run; it does not sandbox what those commands can do. An allowed command like systemctl or journalctl runs with the privileges of the SSH user. Grant only the commands the user genuinely needs, and prefer the least-privileged account possible.

Do not guard the root user

ssh-guard is designed for dedicated, least-privilege accounts, not for root. Applying the guard to root is a bad idea for several reasons:

  • Root is a single point of failure. If the guard misbehaves, the config is wrong, or the binary is broken, you can lock yourself out of the only account that can fix it. A dedicated restricted user can be recreated or bypassed without losing administrative access.
  • Least privilege is undermined. The whole point of the guard is to limit what a compromised session can do. Any command allowed to run as root runs with full system privileges, so a single overly-broad rule becomes a root-equivalent escape hatch.

Instead, create dedicated users, give each only the commands it needs. If you must allow some privileged operations, scope them to a dedicated account with the narrowest possible rule set.

If you update the binary or change the config, re-run validate and re-test an allowed and a denied command. A stale config can silently allow or deny the wrong things.

There aren't any published security advisories