diff --git a/src/ssh_audit/auditconf.py b/src/ssh_audit/auditconf.py index 599f8c2..25a2c06 100644 --- a/src/ssh_audit/auditconf.py +++ b/src/ssh_audit/auditconf.py @@ -56,10 +56,11 @@ class AuditConf: self.threads = 32 self.list_policies = False self.lookup = '' + self.manual = False def __setattr__(self, name: str, value: Union[str, int, float, bool, Sequence[int]]) -> None: valid = False - if name in ['ssh1', 'ssh2', 'batch', 'client_audit', 'colors', 'verbose', 'timeout_set', 'json', 'make_policy', 'list_policies']: + if name in ['ssh1', 'ssh2', 'batch', 'client_audit', 'colors', 'verbose', 'timeout_set', 'json', 'make_policy', 'list_policies', 'manual']: valid, value = True, bool(value) elif name in ['ipv4', 'ipv6']: valid = False diff --git a/src/ssh_audit/globals.py b/src/ssh_audit/globals.py index f62dc1c..925595a 100644 --- a/src/ssh_audit/globals.py +++ b/src/ssh_audit/globals.py @@ -24,3 +24,4 @@ VERSION = 'v2.4.0-dev' SSH_HEADER = 'SSH-{0}-OpenSSH_8.2' # SSH software to impersonate GITHUB_ISSUES_URL = 'https://github.com/jtesta/ssh-audit/issues' # The URL to the Github issues tracker. +WINDOWS_MAN_PAGE = '' diff --git a/src/ssh_audit/ssh_audit.py b/src/ssh_audit/ssh_audit.py index 485c3b8..b95b738 100755 --- a/src/ssh_audit/ssh_audit.py +++ b/src/ssh_audit/ssh_audit.py @@ -36,6 +36,7 @@ from typing import Dict, List, Set, Sequence, Tuple, Iterable # noqa: F401 from typing import Callable, Optional, Union, Any # noqa: F401 from ssh_audit.globals import VERSION +from ssh_audit.globals import WINDOWS_MAN_PAGE from ssh_audit.algorithm import Algorithm from ssh_audit.algorithms import Algorithms from ssh_audit.auditconf import AuditConf @@ -86,6 +87,7 @@ def usage(err: Optional[str] = None) -> None: uout.info(' -L, --list-policies list all the official, built-in policies') uout.info(' --lookup= looks up an algorithm(s) without\n connecting to a server') uout.info(' -M, --make-policy= creates a policy based on the target server\n (i.e.: the target server has the ideal\n configuration that other servers should\n adhere to)') + uout.info(' -m, --manual print the man page (Windows only)') uout.info(' -n, --no-colors disable colors') uout.info(' -p, --port= port to connect') uout.info(' -P, --policy= run a policy test using the specified policy') @@ -571,8 +573,8 @@ def process_commandline(out: OutputBuffer, args: List[str], usage_cb: Callable[. # pylint: disable=too-many-branches aconf = AuditConf() try: - sopts = 'h1246M:p:P:jbcnvl:t:T:L' - lopts = ['help', 'ssh1', 'ssh2', 'ipv4', 'ipv6', 'make-policy=', 'port=', 'policy=', 'json', 'batch', 'client-audit', 'no-colors', 'verbose', 'level=', 'timeout=', 'targets=', 'list-policies', 'lookup=', 'threads='] + sopts = 'h1246M:p:P:jbcnvl:t:T:Lm' + lopts = ['help', 'ssh1', 'ssh2', 'ipv4', 'ipv6', 'make-policy=', 'port=', 'policy=', 'json', 'batch', 'client-audit', 'no-colors', 'verbose', 'level=', 'timeout=', 'targets=', 'list-policies', 'lookup=', 'threads=', 'manual'] opts, args = getopt.gnu_getopt(args, sopts, lopts) except getopt.GetoptError as err: usage_cb(str(err)) @@ -625,10 +627,15 @@ def process_commandline(out: OutputBuffer, args: List[str], usage_cb: Callable[. aconf.list_policies = True elif o == '--lookup': aconf.lookup = a + elif o in ('-m', '--manual'): + aconf.manual = True - if len(args) == 0 and aconf.client_audit is False and aconf.target_file is None and aconf.list_policies is False and aconf.lookup == '': + if len(args) == 0 and aconf.client_audit is False and aconf.target_file is None and aconf.list_policies is False and aconf.lookup == '' and aconf.manual is False: usage_cb() + if aconf.manual: + return aconf + if aconf.lookup != '': return aconf @@ -989,6 +996,79 @@ def target_worker_thread(host: str, port: int, shared_aconf: AuditConf) -> Tuple return ret, string_output +def windows_manual(out: OutputBuffer) -> int: + '''Prints the man page on Windows. Returns an exitcodes.* flag.''' + import os + import ctypes + + retval = exitcodes.GOOD + + if sys.platform != 'win32': + out.fail("The '-m' and '--manual' parameters are reserved for use on Windows only.\nUsers of other operating systems should read the man page.") + retval = exitcodes.FAILURE + return retval + + # Support for ANSI escape sequences was first introduced in Windows 10 + # version 1511. + # + # Calling 'os.system' activates ANSI support if available. + # + # NB: If output is redirected to a file or piped to another program, ANSI + # support is suppressed. + os.system("") + + STD_OUTPUT_HANDLE = -11 + ENABLE_VIRTUAL_TERMINAL_PROCESSING = 4 + + kernel32 = ctypes.WinDLL('kernel32') + hStdin = kernel32.GetStdHandle(STD_OUTPUT_HANDLE) + consoleMode = ctypes.c_ulong() + + # GetConsoleMode + # https://docs.microsoft.com/en-us/windows/console/getconsolemode + # + # Parameters: + # 1. hConsoleHandle [in] + # 2. lpMode [out] + # + # Return value: + # Success: A non-zero value. + # Fail: A value of zero. + kernel32.GetConsoleMode(hStdin, ctypes.byref(consoleMode)) + + # Use a bitwise and (&) between the console mode value and the flag. If the + # console mode value contains the flag then ANSI is supported. + ansi_supported = bool(consoleMode.value & ENABLE_VIRTUAL_TERMINAL_PROCESSING) + + if ansi_supported: + out.info(WINDOWS_MAN_PAGE) + else: + import io + import re + + # If the text contains unicode characters this may result in a + # "UnicodeEncodeError" error when printing depending on the active + # console code page. Therefore the stdout's encoding is explicitly set + # to utf8. + # + # NB: If ANSI support enabled then unicode is implicitly handled. + new_stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf8', errors=sys.stdout.errors) + old_stdout = sys.stdout + sys.stdout = new_stdout + + # An ANSI escape sequence starts with an ESC character (033 in decimal + # and 1b in hex), followed by an open bracket and terminates with 'm'. + strip_ansi = re.compile(r'\x1b\[.*?m') + man_plain_text = strip_ansi.sub('', WINDOWS_MAN_PAGE) + + out.info(man_plain_text) + + new_stdout.detach() + sys.stdout = old_stdout + + return retval + + def main() -> int: out = OutputBuffer() aconf = process_commandline(out, sys.argv[1:], usage) @@ -998,6 +1078,11 @@ def main() -> int: out.json = True out.use_colors = False + if aconf.manual: + retval = windows_manual(out) + out.write() + sys.exit(retval) + if aconf.lookup != '': retval = algorithm_lookup(out, aconf.lookup) out.write() diff --git a/ssh-audit.1 b/ssh-audit.1 index c1ec017..0b62442 100644 --- a/ssh-audit.1 +++ b/ssh-audit.1 @@ -66,6 +66,11 @@ List all official, built-in policies for common systems. Their full names can t .br Look up the security information of an algorithm(s) in the internal database. Does not connect to a server. +.TP +.B -m, \-\-manual +.br +Print the man page (Windows only). + .TP .B -M, \-\-make-policy= .br diff --git a/update_windows_man_page.sh b/update_windows_man_page.sh new file mode 100644 index 0000000..b96a8d5 --- /dev/null +++ b/update_windows_man_page.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +################################################################################ +# update_windows_man_page +# +# PURPOSE +# Since Windows lacks a manual reader it's necessary to provide an alternative +# means of reading the man page. +# +# This script should be run as part of the ssh-audit packaging process for +# Windows. It populates the 'WINDOWS_MAN_PAGE' variable in 'globals.py' with +# the contents of the man page. Windows users can then print the content of +# 'WINDOWS_MAN_PAGE' by invoking ssh-audit with the manual parameters +# (--manual / -m). +# +# USAGE +# update_windows_man_page.sh -m -g +# +################################################################################ + +while getopts "m: g:" OPTION +do + case "$OPTION" in + m) + MAN_PAGE="$OPTARG" + ;; + g) + GLOBALS_PY="$OPTARG" + ;; + *) + echo >&2 "Invalid parameter(s) provided" + exit 1 + ;; + esac +done + +if [[ -z "$MAN_PAGE" || -z "$GLOBALS_PY" ]]; then + echo >&2 "Missing parameter(s)." + exit 1 +fi + +# Check that the specified files exist. +[ -f "$MAN_PAGE" ] || { echo >&2 "man page file not found: $MAN_PAGE"; exit 1; } +[ -f "$GLOBALS_PY" ] || { echo >&2 "globals.py file not found: $GLOBALS_PY"; exit 1; } + +# Check that the 'ul' (do underlining) binary exists. +command -v ul >/dev/null 2>&1 || { echo >&2 "ul not found."; exit 1; } + +# Check that the 'sed' (stream editor) binary exists. +command -v sed >/dev/null 2>&1 || { echo >&2 "sed not found."; exit 1; } + +# Remove the Windows man page placeholder from 'globals.py'. +sed -i '/^WINDOWS_MAN_PAGE/d' "$GLOBALS_PY" + +# Append the man page content to 'globals.py'. +# * man outputs a backspace-overwrite sequence rather than an ANSI escape +# sequence. +# * 'MAN_KEEP_FORMATTING' preserves the backspace-overwrite sequence when +# redirected to a file or a pipe. +# * The 'ul' command converts the backspace-overwrite sequence to an ANSI escape +# sequence. +echo WINDOWS_MAN_PAGE = '"""' >> "$GLOBALS_PY" +MANWIDTH=80 MAN_KEEP_FORMATTING=1 man "$MAN_PAGE" | ul >> "$GLOBALS_PY" +echo '"""' >> "$GLOBALS_PY" \ No newline at end of file