From dd3ca9688e4f0a8d6fc06f6c006dc92617ec9d27 Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Wed, 26 Oct 2016 19:14:03 +0300 Subject: [PATCH 01/45] Back to development version. --- ssh-audit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ssh-audit.py b/ssh-audit.py index 8b67387..bf43e95 100755 --- a/ssh-audit.py +++ b/ssh-audit.py @@ -26,7 +26,7 @@ from __future__ import print_function import os, io, sys, socket, struct, random, errno, getopt, re, hashlib, base64 -VERSION = 'v1.7.0' +VERSION = 'v1.7.1.dev' if sys.version_info >= (3,): # pragma: nocover StringIO, BytesIO = io.StringIO, io.BytesIO From 44c1d4827c73cf2e0dbd63a61734977c429ba96f Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Wed, 2 Nov 2016 13:00:24 +0200 Subject: [PATCH 02/45] Specify error when couldn't get banner. Test for timeout and retry cases. --- ssh-audit.py | 16 ++++--- test/test_errors.py | 106 ++++++++++++++++++++++++++++---------------- 2 files changed, 78 insertions(+), 44 deletions(-) diff --git a/ssh-audit.py b/ssh-audit.py index bf43e95..0dc8bb1 100755 --- a/ssh-audit.py +++ b/ssh-audit.py @@ -1141,16 +1141,17 @@ def connect(self, ipvo=(), cto=3.0, rto=5.0): sys.exit(1) def get_banner(self, sshv=2): - # type: (int) -> Tuple[Optional[SSH.Banner], List[text_type]] + # type: (int) -> Tuple[Optional[SSH.Banner], List[text_type], Optional[str]] banner = 'SSH-{0}-OpenSSH_7.3'.format('1.5' if sshv == 1 else '2.0') rto = self.__sock.gettimeout() self.__sock.settimeout(0.7) s, e = self.recv() self.__sock.settimeout(rto) if s < 0: - return self.__banner, self.__header + return self.__banner, self.__header, e if self.__state < self.SM_BANNER_SENT: self.send_banner(banner) + e = None while self.__banner is None: if not s > 0: s, e = self.recv() @@ -1166,14 +1167,14 @@ def get_banner(self, sshv=2): continue self.__header.append(line) s = 0 - return self.__banner, self.__header + return self.__banner, self.__header, e def recv(self, size=2048): # type: (int) -> Tuple[int, Optional[str]] try: data = self.__sock.recv(size) except socket.timeout: - return (-1, 'timeout') + return (-1, 'timed out') except socket.error as e: if e.args[0] in (errno.EAGAIN, errno.EWOULDBLOCK): return (0, 'retry') @@ -1971,9 +1972,12 @@ def audit(aconf, sshv=None): if sshv is None: sshv = 2 if aconf.ssh2 else 1 err = None - banner, header = s.get_banner(sshv) + banner, header, err = s.get_banner(sshv) if banner is None: - err = '[exception] did not receive banner.' + if err is None: + err = '[exception] did not receive banner.' + else: + err = '[exception] did not receive banner: {0}'.format(err) if err is None: packet_type, payload = s.read_packet(sshv) if packet_type < 0: diff --git a/test/test_errors.py b/test/test_errors.py index ad35a54..e37f60e 100644 --- a/test/test_errors.py +++ b/test/test_errors.py @@ -17,46 +17,91 @@ def _conf(self): conf.batch = True return conf + def _audit(self, spy, conf=None, sysexit=True): + if conf is None: + conf = self._conf() + spy.begin() + if sysexit: + with pytest.raises(SystemExit): + self.audit(conf) + else: + self.audit(conf) + lines = spy.flush() + return lines + def test_connection_refused(self, output_spy, virtual_socket): vsocket = virtual_socket vsocket.errors['connect'] = socket.error(61, 'Connection refused') - output_spy.begin() - with pytest.raises(SystemExit): - self.audit(self._conf()) - lines = output_spy.flush() + lines = self._audit(output_spy) assert len(lines) == 1 assert 'Connection refused' in lines[-1] + def test_connection_timeout(self, output_spy, virtual_socket): + vsocket = virtual_socket + vsocket.errors['connect'] = socket.timeout('timed out') + lines = self._audit(output_spy) + assert len(lines) == 1 + assert 'timed out' in lines[-1] + + def test_recv_empty(self, output_spy, virtual_socket): + vsocket = virtual_socket + lines = self._audit(output_spy) + assert len(lines) == 1 + assert 'did not receive banner' in lines[-1] + + def test_recv_timeout(self, output_spy, virtual_socket): + vsocket = virtual_socket + vsocket.rdata.append(socket.timeout('timed out')) + lines = self._audit(output_spy) + assert len(lines) == 1 + assert 'did not receive banner' in lines[-1] + assert 'timed out' in lines[-1] + + def test_recv_retry_till_timeout(self, output_spy, virtual_socket): + vsocket = virtual_socket + vsocket.rdata.append(socket.error(35, 'Resource temporarily unavailable')) + vsocket.rdata.append(socket.error(35, 'Resource temporarily unavailable')) + vsocket.rdata.append(socket.error(35, 'Resource temporarily unavailable')) + vsocket.rdata.append(socket.timeout('timed out')) + lines = self._audit(output_spy) + assert len(lines) == 1 + assert 'did not receive banner' in lines[-1] + assert 'timed out' in lines[-1] + + def test_recv_retry_till_reset(self, output_spy, virtual_socket): + vsocket = virtual_socket + vsocket.rdata.append(socket.error(35, 'Resource temporarily unavailable')) + vsocket.rdata.append(socket.error(35, 'Resource temporarily unavailable')) + vsocket.rdata.append(socket.error(35, 'Resource temporarily unavailable')) + vsocket.rdata.append(socket.error(54, 'Connection reset by peer')) + lines = self._audit(output_spy) + assert len(lines) == 1 + assert 'did not receive banner' in lines[-1] + assert 'reset by peer' in lines[-1] + def test_connection_closed_before_banner(self, output_spy, virtual_socket): vsocket = virtual_socket vsocket.rdata.append(socket.error(54, 'Connection reset by peer')) - output_spy.begin() - with pytest.raises(SystemExit): - self.audit(self._conf()) - lines = output_spy.flush() + lines = self._audit(output_spy) assert len(lines) == 1 assert 'did not receive banner' in lines[-1] + assert 'reset by peer' in lines[-1] def test_connection_closed_after_header(self, output_spy, virtual_socket): vsocket = virtual_socket vsocket.rdata.append(b'header line 1\n') vsocket.rdata.append(b'header line 2\n') vsocket.rdata.append(socket.error(54, 'Connection reset by peer')) - output_spy.begin() - with pytest.raises(SystemExit): - self.audit(self._conf()) - lines = output_spy.flush() + lines = self._audit(output_spy) assert len(lines) == 3 assert 'did not receive banner' in lines[-1] + assert 'reset by peer' in lines[-1] def test_connection_closed_after_banner(self, output_spy, virtual_socket): vsocket = virtual_socket vsocket.rdata.append(b'SSH-2.0-ssh-audit-test\r\n') vsocket.rdata.append(socket.error(54, 'Connection reset by peer')) - output_spy.begin() - with pytest.raises(SystemExit): - self.audit(self._conf()) - lines = output_spy.flush() + lines = self._audit(output_spy) assert len(lines) == 2 assert 'error reading packet' in lines[-1] assert 'reset by peer' in lines[-1] @@ -64,10 +109,7 @@ def test_connection_closed_after_banner(self, output_spy, virtual_socket): def test_empty_data_after_banner(self, output_spy, virtual_socket): vsocket = virtual_socket vsocket.rdata.append(b'SSH-2.0-ssh-audit-test\r\n') - output_spy.begin() - with pytest.raises(SystemExit): - self.audit(self._conf()) - lines = output_spy.flush() + lines = self._audit(output_spy) assert len(lines) == 2 assert 'error reading packet' in lines[-1] assert 'empty' in lines[-1] @@ -76,10 +118,7 @@ def test_wrong_data_after_banner(self, output_spy, virtual_socket): vsocket = virtual_socket vsocket.rdata.append(b'SSH-2.0-ssh-audit-test\r\n') vsocket.rdata.append(b'xxx\n') - output_spy.begin() - with pytest.raises(SystemExit): - self.audit(self._conf()) - lines = output_spy.flush() + lines = self._audit(output_spy) assert len(lines) == 2 assert 'error reading packet' in lines[-1] assert 'xxx' in lines[-1] @@ -87,10 +126,7 @@ def test_wrong_data_after_banner(self, output_spy, virtual_socket): def test_non_ascii_banner(self, output_spy, virtual_socket): vsocket = virtual_socket vsocket.rdata.append(b'SSH-2.0-ssh-audit-test\xc3\xbc\r\n') - output_spy.begin() - with pytest.raises(SystemExit): - self.audit(self._conf()) - lines = output_spy.flush() + lines = self._audit(output_spy) assert len(lines) == 3 assert 'error reading packet' in lines[-1] assert 'ASCII' in lines[-2] @@ -100,10 +136,7 @@ def test_nonutf8_data_after_banner(self, output_spy, virtual_socket): vsocket = virtual_socket vsocket.rdata.append(b'SSH-2.0-ssh-audit-test\r\n') vsocket.rdata.append(b'\x81\xff\n') - output_spy.begin() - with pytest.raises(SystemExit): - self.audit(self._conf()) - lines = output_spy.flush() + lines = self._audit(output_spy) assert len(lines) == 2 assert 'error reading packet' in lines[-1] assert '\\x81\\xff' in lines[-1] @@ -112,12 +145,9 @@ def test_protocol_mismatch_by_conf(self, output_spy, virtual_socket): vsocket = virtual_socket vsocket.rdata.append(b'SSH-1.3-ssh-audit-test\r\n') vsocket.rdata.append(b'Protocol major versions differ.\n') - output_spy.begin() - with pytest.raises(SystemExit): - conf = self._conf() - conf.ssh1, conf.ssh2 = True, False - self.audit(conf) - lines = output_spy.flush() + conf = self._conf() + conf.ssh1, conf.ssh2 = True, False + lines = self._audit(output_spy, conf) assert len(lines) == 3 assert 'error reading packet' in lines[-1] assert 'major versions differ' in lines[-1] From b3ed4c7715577851c81971b52083591a0e2d267d Mon Sep 17 00:00:00 2001 From: Fabio Alessandro Locati Date: Wed, 2 Nov 2016 10:45:56 +0000 Subject: [PATCH 03/45] Add LICENSE file (#22) Create LICENSE --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0eb1032 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (C) 2016 Andris Raugulis (moo@arthepsy.eu) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 11b6155c64e66f53acdcb00349f2b29b46a52fab Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Wed, 2 Nov 2016 13:18:03 +0200 Subject: [PATCH 04/45] Use Python defined error numbers. --- test/test_errors.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/test/test_errors.py b/test/test_errors.py index e37f60e..4f3d6cc 100644 --- a/test/test_errors.py +++ b/test/test_errors.py @@ -1,6 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- import socket +import errno import pytest @@ -31,7 +32,7 @@ def _audit(self, spy, conf=None, sysexit=True): def test_connection_refused(self, output_spy, virtual_socket): vsocket = virtual_socket - vsocket.errors['connect'] = socket.error(61, 'Connection refused') + vsocket.errors['connect'] = socket.error(errno.ECONNREFUSED, 'Connection refused') lines = self._audit(output_spy) assert len(lines) == 1 assert 'Connection refused' in lines[-1] @@ -59,9 +60,9 @@ def test_recv_timeout(self, output_spy, virtual_socket): def test_recv_retry_till_timeout(self, output_spy, virtual_socket): vsocket = virtual_socket - vsocket.rdata.append(socket.error(35, 'Resource temporarily unavailable')) - vsocket.rdata.append(socket.error(35, 'Resource temporarily unavailable')) - vsocket.rdata.append(socket.error(35, 'Resource temporarily unavailable')) + vsocket.rdata.append(socket.error(errno.EAGAIN, 'Resource temporarily unavailable')) + vsocket.rdata.append(socket.error(errno.EWOULDBLOCK, 'Resource temporarily unavailable')) + vsocket.rdata.append(socket.error(errno.EAGAIN, 'Resource temporarily unavailable')) vsocket.rdata.append(socket.timeout('timed out')) lines = self._audit(output_spy) assert len(lines) == 1 @@ -70,10 +71,10 @@ def test_recv_retry_till_timeout(self, output_spy, virtual_socket): def test_recv_retry_till_reset(self, output_spy, virtual_socket): vsocket = virtual_socket - vsocket.rdata.append(socket.error(35, 'Resource temporarily unavailable')) - vsocket.rdata.append(socket.error(35, 'Resource temporarily unavailable')) - vsocket.rdata.append(socket.error(35, 'Resource temporarily unavailable')) - vsocket.rdata.append(socket.error(54, 'Connection reset by peer')) + vsocket.rdata.append(socket.error(errno.EAGAIN, 'Resource temporarily unavailable')) + vsocket.rdata.append(socket.error(errno.EWOULDBLOCK, 'Resource temporarily unavailable')) + vsocket.rdata.append(socket.error(errno.EAGAIN, 'Resource temporarily unavailable')) + vsocket.rdata.append(socket.error(errno.ECONNRESET, 'Connection reset by peer')) lines = self._audit(output_spy) assert len(lines) == 1 assert 'did not receive banner' in lines[-1] @@ -81,7 +82,7 @@ def test_recv_retry_till_reset(self, output_spy, virtual_socket): def test_connection_closed_before_banner(self, output_spy, virtual_socket): vsocket = virtual_socket - vsocket.rdata.append(socket.error(54, 'Connection reset by peer')) + vsocket.rdata.append(socket.error(errno.ECONNRESET, 'Connection reset by peer')) lines = self._audit(output_spy) assert len(lines) == 1 assert 'did not receive banner' in lines[-1] @@ -91,7 +92,7 @@ def test_connection_closed_after_header(self, output_spy, virtual_socket): vsocket = virtual_socket vsocket.rdata.append(b'header line 1\n') vsocket.rdata.append(b'header line 2\n') - vsocket.rdata.append(socket.error(54, 'Connection reset by peer')) + vsocket.rdata.append(socket.error(errno.ECONNRESET, 'Connection reset by peer')) lines = self._audit(output_spy) assert len(lines) == 3 assert 'did not receive banner' in lines[-1] From 5bb0ae0ceb2acd9a37e8ea9c500e49500afa1d47 Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Wed, 2 Nov 2016 18:23:55 +0200 Subject: [PATCH 05/45] Rework is/to ASCII and implement printable ASCII is/to functions. Add Utils tests. --- ssh-audit.py | 64 ++++++++++--- test/test_utils.py | 218 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+), 13 deletions(-) create mode 100644 test/test_utils.py diff --git a/ssh-audit.py b/ssh-audit.py index 0dc8bb1..9100a31 100755 --- a/ssh-audit.py +++ b/ssh-audit.py @@ -1914,28 +1914,58 @@ def to_ntext(cls, v, enc='utf-8'): if isinstance(v, str): return v elif isinstance(v, text_type): - return v.encode(enc) + return v.encode(enc) # PY2 only elif isinstance(v, binary_type): - return v.decode(enc) + return v.decode(enc) # PY3 only raise cls._type_err(v, 'native text') + @classmethod + def _is_ascii(cls, v, char_filter=lambda x: x <= 127): + # type: (Union[text_type, str], Callable[[int], bool]) -> bool + r = False + if isinstance(v, (text_type, str)): + for c in v: + i = cls.ctoi(c) + if not char_filter(i): + return r + r = True + return r + + @classmethod + def _to_ascii(cls, v, char_filter=lambda x: x <= 127, errors='replace'): + # type: (Union[text_type, str], Callable[[int], bool], str) -> str + if isinstance(v, (text_type, str)): + r = bytearray() + for c in v: + i = cls.ctoi(c) + if char_filter(i): + r.append(i) + else: + if errors == 'ignore': + continue + r.append(63) + return cls.to_ntext(r.decode('ascii')) + raise cls._type_err(v, 'ascii') + @classmethod def is_ascii(cls, v): # type: (Union[text_type, str]) -> bool - try: - if isinstance(v, (text_type, str)): - v.encode('ascii') - return True - except UnicodeEncodeError: - pass - return False + return cls._is_ascii(v) @classmethod def to_ascii(cls, v, errors='replace'): # type: (Union[text_type, str], str) -> str - if isinstance(v, (text_type, str)): - return cls.to_ntext(v.encode('ascii', errors)) - raise cls._type_err(v, 'ascii') + return cls._to_ascii(v, errors=errors) + + @classmethod + def is_print_ascii(cls, v): + # type: (Union[text_type, str]) -> bool + return cls._is_ascii(v, lambda x: x >= 32 and x <= 126) + + @classmethod + def to_print_ascii(cls, v, errors='replace'): + # type: (Union[text_type, str], str) -> str + return cls._to_ascii(v, lambda x: x >= 32 and x <= 126, errors) @classmethod def unique_seq(cls, seq): @@ -1951,7 +1981,15 @@ def _seen_add(x): return tuple(x for x in seq if x not in seen and not _seen_add(x)) else: return [x for x in seq if x not in seen and not _seen_add(x)] - + + @classmethod + def ctoi(cls, c): + # type: (Union[text_type, str, int]) -> int + if isinstance(c, (text_type, str)): + return ord(c[0]) + else: + return c + @staticmethod def parse_int(v): # type: (Any) -> int diff --git a/test/test_utils.py b/test/test_utils.py new file mode 100644 index 0000000..64cb07b --- /dev/null +++ b/test/test_utils.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import sys +import pytest + + +# pylint: disable=attribute-defined-outside-init +class TestUtils(object): + @pytest.fixture(autouse=True) + def init(self, ssh_audit): + self.utils = ssh_audit.Utils + self.PY3 = sys.version_info >= (3,) + + def test_to_bytes_py2(self): + if self.PY3: + return + # binary_type (native str, bytes as str) + assert self.utils.to_bytes('fran\xc3\xa7ais') == 'fran\xc3\xa7ais' + assert self.utils.to_bytes(b'fran\xc3\xa7ais') == 'fran\xc3\xa7ais' + # text_type (unicode) + assert self.utils.to_bytes(u'fran\xe7ais') == 'fran\xc3\xa7ais' + # other + with pytest.raises(TypeError): + self.utils.to_bytes(123) + + def test_to_bytes_py3(self): + if not self.PY3: + return + # binary_type (bytes) + assert self.utils.to_bytes(b'fran\xc3\xa7ais') == b'fran\xc3\xa7ais' + # text_type (native str as unicode, unicode) + assert self.utils.to_bytes('fran\xe7ais') == b'fran\xc3\xa7ais' + assert self.utils.to_bytes(u'fran\xe7ais') == b'fran\xc3\xa7ais' + # other + with pytest.raises(TypeError): + self.utils.to_bytes(123) + + def test_to_utext_py2(self): + if self.PY3: + return + # binary_type (native str, bytes as str) + assert self.utils.to_utext('fran\xc3\xa7ais') == u'fran\xe7ais' + assert self.utils.to_utext(b'fran\xc3\xa7ais') == u'fran\xe7ais' + # text_type (unicode) + assert self.utils.to_utext(u'fran\xe7ais') == u'fran\xe7ais' + # other + with pytest.raises(TypeError): + self.utils.to_utext(123) + + def test_to_utext_py3(self): + if not self.PY3: + return + # binary_type (bytes) + assert self.utils.to_utext(b'fran\xc3\xa7ais') == u'fran\xe7ais' + # text_type (native str as unicode, unicode) + assert self.utils.to_utext('fran\xe7ais') == 'fran\xe7ais' + assert self.utils.to_utext(u'fran\xe7ais') == u'fran\xe7ais' + # other + with pytest.raises(TypeError): + self.utils.to_utext(123) + + def test_to_ntext_py2(self): + if self.PY3: + return + # str (native str, bytes as str) + assert self.utils.to_ntext('fran\xc3\xa7ais') == 'fran\xc3\xa7ais' + assert self.utils.to_ntext(b'fran\xc3\xa7ais') == 'fran\xc3\xa7ais' + # text_type (unicode) + assert self.utils.to_ntext(u'fran\xe7ais') == 'fran\xc3\xa7ais' + # other + with pytest.raises(TypeError): + self.utils.to_ntext(123) + + def test_to_ntext_py3(self): + if not self.PY3: + return + # str (native str) + assert self.utils.to_ntext('fran\xc3\xa7ais') == 'fran\xc3\xa7ais' + assert self.utils.to_ntext(u'fran\xe7ais') == 'fran\xe7ais' + # binary_type (bytes) + assert self.utils.to_utext(b'fran\xc3\xa7ais') == 'fran\xe7ais' + # other + with pytest.raises(TypeError): + self.utils.to_ntext(123) + + def test_is_ascii_py2(self): + if self.PY3: + return + # text_type (unicode) + assert self.utils.is_ascii(u'francais') is True + assert self.utils.is_ascii(u'fran\xe7ais') is False + # str + assert self.utils.is_ascii('francais') is True + assert self.utils.is_ascii('fran\xc3\xa7ais') is False + # other + assert self.utils.is_ascii(123) is False + + def test_is_ascii_py3(self): + if not self.PY3: + return + # text_type (str) + assert self.utils.is_ascii('francais') is True + assert self.utils.is_ascii(u'francais') is True + assert self.utils.is_ascii('fran\xe7ais') is False + assert self.utils.is_ascii(u'fran\xe7ais') is False + # other + assert self.utils.is_ascii(123) is False + + def test_to_ascii_py2(self): + if self.PY3: + return + # text_type (unicode) + assert self.utils.to_ascii(u'francais') == 'francais' + assert self.utils.to_ascii(u'fran\xe7ais') == 'fran?ais' + assert self.utils.to_ascii(u'fran\xe7ais', 'ignore') == 'franais' + # str + assert self.utils.to_ascii('francais') == 'francais' + assert self.utils.to_ascii('fran\xc3\xa7ais') == 'fran??ais' + assert self.utils.to_ascii('fran\xc3\xa7ais', 'ignore') == 'franais' + with pytest.raises(TypeError): + self.utils.to_ascii(123) + + def test_to_ascii_py3(self): + if not self.PY3: + return + # text_type (str) + assert self.utils.to_ascii('francais') == 'francais' + assert self.utils.to_ascii(u'francais') == 'francais' + assert self.utils.to_ascii('fran\xe7ais') == 'fran?ais' + assert self.utils.to_ascii('fran\xe7ais', 'ignore') == 'franais' + assert self.utils.to_ascii(u'fran\xe7ais') == 'fran?ais' + assert self.utils.to_ascii(u'fran\xe7ais', 'ignore') == 'franais' + with pytest.raises(TypeError): + self.utils.to_ascii(123) + + def test_is_print_ascii_py2(self): + if self.PY3: + return + # text_type (unicode) + assert self.utils.is_print_ascii(u'francais') is True + assert self.utils.is_print_ascii(u'francais\n') is False + assert self.utils.is_print_ascii(u'fran\xe7ais') is False + assert self.utils.is_print_ascii(u'fran\xe7ais\n') is False + # str + assert self.utils.is_print_ascii('francais') is True + assert self.utils.is_print_ascii('francais\n') is False + assert self.utils.is_print_ascii('fran\xc3\xa7ais') is False + # other + assert self.utils.is_print_ascii(123) is False + + def test_is_print_ascii_py3(self): + if not self.PY3: + return + # text_type (str) + assert self.utils.is_print_ascii('francais') is True + assert self.utils.is_print_ascii('francais\n') is False + assert self.utils.is_print_ascii(u'francais') is True + assert self.utils.is_print_ascii(u'francais\n') is False + assert self.utils.is_print_ascii('fran\xe7ais') is False + assert self.utils.is_print_ascii(u'fran\xe7ais') is False + # other + assert self.utils.is_print_ascii(123) is False + + def test_to_print_ascii_py2(self): + if self.PY3: + return + # text_type (unicode) + assert self.utils.to_print_ascii(u'francais') == 'francais' + assert self.utils.to_print_ascii(u'francais\n') == 'francais?' + assert self.utils.to_print_ascii(u'fran\xe7ais') == 'fran?ais' + assert self.utils.to_print_ascii(u'fran\xe7ais\n') == 'fran?ais?' + assert self.utils.to_print_ascii(u'fran\xe7ais', 'ignore') == 'franais' + assert self.utils.to_print_ascii(u'fran\xe7ais\n', 'ignore') == 'franais' + # str + assert self.utils.to_print_ascii('francais') == 'francais' + assert self.utils.to_print_ascii('francais\n') == 'francais?' + assert self.utils.to_print_ascii('fran\xc3\xa7ais') == 'fran??ais' + assert self.utils.to_print_ascii('fran\xc3\xa7ais\n') == 'fran??ais?' + assert self.utils.to_print_ascii('fran\xc3\xa7ais', 'ignore') == 'franais' + assert self.utils.to_print_ascii('fran\xc3\xa7ais\n', 'ignore') == 'franais' + with pytest.raises(TypeError): + self.utils.to_print_ascii(123) + + def test_to_print_ascii_py3(self): + if not self.PY3: + return + # text_type (str) + assert self.utils.to_print_ascii('francais') == 'francais' + assert self.utils.to_print_ascii('francais\n') == 'francais?' + assert self.utils.to_print_ascii(u'francais') == 'francais' + assert self.utils.to_print_ascii(u'francais\n') == 'francais?' + assert self.utils.to_print_ascii('fran\xe7ais') == 'fran?ais' + assert self.utils.to_print_ascii('fran\xe7ais\n') == 'fran?ais?' + assert self.utils.to_print_ascii('fran\xe7ais', 'ignore') == 'franais' + assert self.utils.to_print_ascii('fran\xe7ais\n', 'ignore') == 'franais' + assert self.utils.to_print_ascii(u'fran\xe7ais') == 'fran?ais' + assert self.utils.to_print_ascii(u'fran\xe7ais\n') == 'fran?ais?' + assert self.utils.to_print_ascii(u'fran\xe7ais', 'ignore') == 'franais' + assert self.utils.to_print_ascii(u'fran\xe7ais\n', 'ignore') == 'franais' + with pytest.raises(TypeError): + self.utils.to_print_ascii(123) + + def test_ctoi(self): + assert self.utils.ctoi(123) == 123 + assert self.utils.ctoi('ABC') == 65 + + def test_parse_int(self): + assert self.utils.parse_int(123) == 123 + assert self.utils.parse_int('123') == 123 + assert self.utils.parse_int(-123) == -123 + assert self.utils.parse_int('-123') == -123 + assert self.utils.parse_int('abc') == 0 + + def test_unique_seq(self): + assert self.utils.unique_seq((1, 2, 2, 3, 3, 3)) == (1, 2, 3) + assert self.utils.unique_seq((3, 3, 3, 2, 2, 1)) == (3, 2, 1) + assert self.utils.unique_seq([1, 2, 2, 3, 3, 3]) == [1, 2, 3] + assert self.utils.unique_seq([3, 3, 3, 2, 2, 1]) == [3, 2, 1] From 6c4b9fcadfe7041be4a0620d1e6e59c3c396e27d Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Wed, 2 Nov 2016 18:25:13 +0200 Subject: [PATCH 06/45] Banner should be in printable ASCII, not the whole ASCII space. --- ssh-audit.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ssh-audit.py b/ssh-audit.py index 9100a31..958d995 100755 --- a/ssh-audit.py +++ b/ssh-audit.py @@ -1005,8 +1005,8 @@ def __repr__(self): @classmethod def parse(cls, banner): # type: (text_type) -> SSH.Banner - valid_ascii = utils.is_ascii(banner) - ascii_banner = utils.to_ascii(banner) + valid_ascii = utils.is_print_ascii(banner) + ascii_banner = utils.to_print_ascii(banner) mx = cls.RX_BANNER.match(ascii_banner) if mx is None: return None From 6fde896d77a43b40c27894954a394d06a9cc1cc0 Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Wed, 2 Nov 2016 19:28:16 +0200 Subject: [PATCH 07/45] Add resolve tests. --- test/conftest.py | 54 ++++++++++++++++++++-------- test/test_errors.py | 8 +++++ test/test_resolve.py | 85 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 14 deletions(-) create mode 100644 test/test_resolve.py diff --git a/test/conftest.py b/test/conftest.py index 524c0fa..0bc4124 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -40,6 +40,41 @@ def output_spy(): return _OutputSpy() +class _VirtualGlobalSocket(object): + def __init__(self, vsocket): + self.vsocket = vsocket + self.addrinfodata = {} + + # pylint: disable=unused-argument + def create_connection(self, address, timeout=0, source_address=None): + # pylint: disable=protected-access + return self.vsocket._connect(address, True) + + # pylint: disable=unused-argument + def socket(self, + family=socket.AF_INET, + socktype=socket.SOCK_STREAM, + proto=0, + fileno=None): + return self.vsocket + + def getaddrinfo(self, host, port, family=0, socktype=0, proto=0, flags=0): + key = '{0}#{1}'.format(host, port) + if key in self.addrinfodata: + data = self.addrinfodata[key] + if isinstance(data, Exception): + raise data + return data + if host == 'localhost': + r = [] + if family in (0, socket.AF_INET): + r.append((socket.AF_INET, 1, 6, '', ('127.0.0.1', port))) + if family in (0, socket.AF_INET6): + r.append((socket.AF_INET6, 1, 6, '', ('::1', port))) + return r + return [] + + class _VirtualSocket(object): def __init__(self): self.sock_address = ('127.0.0.1', 0) @@ -49,6 +84,7 @@ def __init__(self): self.rdata = [] self.sdata = [] self.errors = {} + self.gsock = _VirtualGlobalSocket(self) def _check_err(self, method): method_error = self.errors.get(method) @@ -113,18 +149,8 @@ def send(self, data): @pytest.fixture() def virtual_socket(monkeypatch): vsocket = _VirtualSocket() - - # pylint: disable=unused-argument - def _socket(family=socket.AF_INET, - socktype=socket.SOCK_STREAM, - proto=0, - fileno=None): - return vsocket - - def _cc(address, timeout=0, source_address=None): - # pylint: disable=protected-access - return vsocket._connect(address, True) - - monkeypatch.setattr(socket, 'create_connection', _cc) - monkeypatch.setattr(socket, 'socket', _socket) + gsock = vsocket.gsock + monkeypatch.setattr(socket, 'create_connection', gsock.create_connection) + monkeypatch.setattr(socket, 'socket', gsock.socket) + monkeypatch.setattr(socket, 'getaddrinfo', gsock.getaddrinfo) return vsocket diff --git a/test/test_errors.py b/test/test_errors.py index 4f3d6cc..abf720e 100644 --- a/test/test_errors.py +++ b/test/test_errors.py @@ -30,6 +30,13 @@ def _audit(self, spy, conf=None, sysexit=True): lines = spy.flush() return lines + def test_connection_unresolved(self, output_spy, virtual_socket): + vsocket = virtual_socket + vsocket.gsock.addrinfodata['localhost#22'] = [] + lines = self._audit(output_spy) + assert len(lines) == 1 + assert 'has no DNS records' in lines[-1] + def test_connection_refused(self, output_spy, virtual_socket): vsocket = virtual_socket vsocket.errors['connect'] = socket.error(errno.ECONNREFUSED, 'Connection refused') @@ -91,6 +98,7 @@ def test_connection_closed_before_banner(self, output_spy, virtual_socket): def test_connection_closed_after_header(self, output_spy, virtual_socket): vsocket = virtual_socket vsocket.rdata.append(b'header line 1\n') + vsocket.rdata.append(b'\n') vsocket.rdata.append(b'header line 2\n') vsocket.rdata.append(socket.error(errno.ECONNRESET, 'Connection reset by peer')) lines = self._audit(output_spy) diff --git a/test/test_resolve.py b/test/test_resolve.py new file mode 100644 index 0000000..8fcddf6 --- /dev/null +++ b/test/test_resolve.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import socket +import pytest + + +# pylint: disable=attribute-defined-outside-init,protected-access +class TestResolve(object): + @pytest.fixture(autouse=True) + def init(self, ssh_audit): + self.AuditConf = ssh_audit.AuditConf + self.audit = ssh_audit.audit + self.ssh = ssh_audit.SSH + + def _conf(self): + conf = self.AuditConf('localhost', 22) + conf.colors = False + conf.batch = True + return conf + + def test_resolve_error(self, output_spy, virtual_socket): + vsocket = virtual_socket + vsocket.gsock.addrinfodata['localhost#22'] = socket.gaierror(8, 'hostname nor servname provided, or not known') + s = self.ssh.Socket('localhost', 22) + conf = self._conf() + output_spy.begin() + with pytest.raises(SystemExit): + r = list(s._resolve(conf.ipvo)) + lines = output_spy.flush() + assert len(lines) == 1 + assert 'hostname nor servname provided' in lines[-1] + + def test_resolve_hostname_without_records(self, output_spy, virtual_socket): + vsocket = virtual_socket + vsocket.gsock.addrinfodata['localhost#22'] = [] + s = self.ssh.Socket('localhost', 22) + conf = self._conf() + output_spy.begin() + r = list(s._resolve(conf.ipvo)) + assert len(r) == 0 + + def test_resolve_ipv4(self, virtual_socket): + vsocket = virtual_socket + conf = self._conf() + conf.ipv4 = True + s = self.ssh.Socket('localhost', 22) + r = list(s._resolve(conf.ipvo)) + assert len(r) == 1 + assert r[0] == (socket.AF_INET, ('127.0.0.1', 22)) + + def test_resolve_ipv6(self, virtual_socket): + vsocket = virtual_socket + s = self.ssh.Socket('localhost', 22) + conf = self._conf() + conf.ipv6 = True + r = list(s._resolve(conf.ipvo)) + assert len(r) == 1 + assert r[0] == (socket.AF_INET6, ('::1', 22)) + + def test_resolve_ipv46_both(self, virtual_socket): + vsocket = virtual_socket + s = self.ssh.Socket('localhost', 22) + conf = self._conf() + r = list(s._resolve(conf.ipvo)) + assert len(r) == 2 + assert r[0] == (socket.AF_INET, ('127.0.0.1', 22)) + assert r[1] == (socket.AF_INET6, ('::1', 22)) + + def test_resolve_ipv46_order(self, virtual_socket): + vsocket = virtual_socket + s = self.ssh.Socket('localhost', 22) + conf = self._conf() + conf.ipv4 = True + conf.ipv6 = True + r = list(s._resolve(conf.ipvo)) + assert len(r) == 2 + assert r[0] == (socket.AF_INET, ('127.0.0.1', 22)) + assert r[1] == (socket.AF_INET6, ('::1', 22)) + conf = self._conf() + conf.ipv6 = True + conf.ipv4 = True + r = list(s._resolve(conf.ipvo)) + assert len(r) == 2 + assert r[0] == (socket.AF_INET6, ('::1', 22)) + assert r[1] == (socket.AF_INET, ('127.0.0.1', 22)) From 9a409e835ea4c1789a13f9011c2d9fa293e515ad Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Thu, 3 Nov 2016 19:10:49 +0200 Subject: [PATCH 08/45] Refactor outer functions within classes. Use mypy strict optional checks and fix them. Use better comparison for compatiblity output. Add initial socket tests. --- ssh-audit.py | 796 ++++++++++++++++++++++++-------------------- test/mypy-py2.sh | 2 +- test/mypy-py3.sh | 2 +- test/test_socket.py | 41 +++ 4 files changed, 473 insertions(+), 368 deletions(-) create mode 100644 test/test_socket.py diff --git a/ssh-audit.py b/ssh-audit.py index 958d995..0d86e03 100755 --- a/ssh-audit.py +++ b/ssh-audit.py @@ -267,6 +267,117 @@ def __exit__(self, *args): class SSH2(object): # pylint: disable=too-few-public-methods + class KexDB(object): # pylint: disable=too-few-public-methods + # pylint: disable=bad-whitespace + WARN_OPENSSH72_LEGACY = 'disabled (in client) since OpenSSH 7.2, legacy algorithm' + FAIL_OPENSSH70_LEGACY = 'removed since OpenSSH 7.0, legacy algorithm' + FAIL_OPENSSH70_WEAK = 'removed (in server) and disabled (in client) since OpenSSH 7.0, weak algorithm' + FAIL_OPENSSH70_LOGJAM = 'disabled (in client) since OpenSSH 7.0, logjam attack' + INFO_OPENSSH69_CHACHA = 'default cipher since OpenSSH 6.9.' + FAIL_OPENSSH67_UNSAFE = 'removed (in server) since OpenSSH 6.7, unsafe algorithm' + FAIL_OPENSSH61_REMOVE = 'removed since OpenSSH 6.1, removed from specification' + FAIL_OPENSSH31_REMOVE = 'removed since OpenSSH 3.1' + FAIL_DBEAR67_DISABLED = 'disabled since Dropbear SSH 2015.67' + FAIL_DBEAR53_DISABLED = 'disabled since Dropbear SSH 0.53' + FAIL_PLAINTEXT = 'no encryption/integrity' + WARN_CURVES_WEAK = 'using weak elliptic curves' + WARN_RNDSIG_KEY = 'using weak random number generator could reveal the key' + WARN_MODULUS_SIZE = 'using small 1024-bit modulus' + WARN_MODULUS_CUSTOM = 'using custom size modulus (possibly weak)' + WARN_HASH_WEAK = 'using weak hashing algorithm' + WARN_CIPHER_MODE = 'using weak cipher mode' + WARN_BLOCK_SIZE = 'using small 64-bit block size' + WARN_CIPHER_WEAK = 'using weak cipher' + WARN_ENCRYPT_AND_MAC = 'using encrypt-and-MAC mode' + WARN_TAG_SIZE = 'using small 64-bit tag size' + + ALGORITHMS = { + 'kex': { + 'diffie-hellman-group1-sha1': [['2.3.0,d0.28,l10.2', '6.6', '6.9'], [FAIL_OPENSSH67_UNSAFE, FAIL_OPENSSH70_LOGJAM], [WARN_MODULUS_SIZE, WARN_HASH_WEAK]], + 'diffie-hellman-group14-sha1': [['3.9,d0.53,l10.6.0'], [], [WARN_HASH_WEAK]], + 'diffie-hellman-group14-sha256': [['7.3,d2016.73']], + 'diffie-hellman-group16-sha512': [['7.3,d2016.73']], + 'diffie-hellman-group18-sha512': [['7.3']], + 'diffie-hellman-group-exchange-sha1': [['2.3.0', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_HASH_WEAK]], + 'diffie-hellman-group-exchange-sha256': [['4.4'], [], [WARN_MODULUS_CUSTOM]], + 'ecdh-sha2-nistp256': [['5.7,d2013.62,l10.6.0'], [WARN_CURVES_WEAK]], + 'ecdh-sha2-nistp384': [['5.7,d2013.62'], [WARN_CURVES_WEAK]], + 'ecdh-sha2-nistp521': [['5.7,d2013.62'], [WARN_CURVES_WEAK]], + 'curve25519-sha256@libssh.org': [['6.5,d2013.62,l10.6.0']], + 'kexguess2@matt.ucc.asn.au': [['d2013.57']], + }, + 'key': { + 'rsa-sha2-256': [['7.2']], + 'rsa-sha2-512': [['7.2']], + 'ssh-ed25519': [['6.5,l10.7.0']], + 'ssh-ed25519-cert-v01@openssh.com': [['6.5']], + 'ssh-rsa': [['2.5.0,d0.28,l10.2']], + 'ssh-dss': [['2.1.0,d0.28,l10.2', '6.9'], [FAIL_OPENSSH70_WEAK], [WARN_MODULUS_SIZE, WARN_RNDSIG_KEY]], + 'ecdsa-sha2-nistp256': [['5.7,d2013.62,l10.6.4'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]], + 'ecdsa-sha2-nistp384': [['5.7,d2013.62,l10.6.4'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]], + 'ecdsa-sha2-nistp521': [['5.7,d2013.62,l10.6.4'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]], + 'ssh-rsa-cert-v00@openssh.com': [['5.4', '6.9'], [FAIL_OPENSSH70_LEGACY], []], + 'ssh-dss-cert-v00@openssh.com': [['5.4', '6.9'], [FAIL_OPENSSH70_LEGACY], [WARN_MODULUS_SIZE, WARN_RNDSIG_KEY]], + 'ssh-rsa-cert-v01@openssh.com': [['5.6']], + 'ssh-dss-cert-v01@openssh.com': [['5.6', '6.9'], [FAIL_OPENSSH70_WEAK], [WARN_MODULUS_SIZE, WARN_RNDSIG_KEY]], + 'ecdsa-sha2-nistp256-cert-v01@openssh.com': [['5.7'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]], + 'ecdsa-sha2-nistp384-cert-v01@openssh.com': [['5.7'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]], + 'ecdsa-sha2-nistp521-cert-v01@openssh.com': [['5.7'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]], + }, + 'enc': { + 'none': [['1.2.2,d2013.56,l10.2'], [FAIL_PLAINTEXT]], + '3des-cbc': [['1.2.2,d0.28,l10.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_CIPHER_WEAK, WARN_CIPHER_MODE, WARN_BLOCK_SIZE]], + '3des-ctr': [['d0.52']], + 'blowfish-cbc': [['1.2.2,d0.28,l10.2', '6.6,d0.52', '7.1,d0.52'], [FAIL_OPENSSH67_UNSAFE, FAIL_DBEAR53_DISABLED], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_MODE, WARN_BLOCK_SIZE]], + 'twofish-cbc': [['d0.28', 'd2014.66'], [FAIL_DBEAR67_DISABLED], [WARN_CIPHER_MODE]], + 'twofish128-cbc': [['d0.47', 'd2014.66'], [FAIL_DBEAR67_DISABLED], [WARN_CIPHER_MODE]], + 'twofish256-cbc': [['d0.47', 'd2014.66'], [FAIL_DBEAR67_DISABLED], [WARN_CIPHER_MODE]], + 'twofish128-ctr': [['d2015.68']], + 'twofish256-ctr': [['d2015.68']], + 'cast128-cbc': [['2.1.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_MODE, WARN_BLOCK_SIZE]], + 'arcfour': [['2.1.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_WEAK]], + 'arcfour128': [['4.2', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_WEAK]], + 'arcfour256': [['4.2', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_WEAK]], + 'aes128-cbc': [['2.3.0,d0.28,l10.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_CIPHER_MODE]], + 'aes192-cbc': [['2.3.0,l10.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_CIPHER_MODE]], + 'aes256-cbc': [['2.3.0,d0.47,l10.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_CIPHER_MODE]], + 'rijndael128-cbc': [['2.3.0', '3.0.2'], [FAIL_OPENSSH31_REMOVE], [WARN_CIPHER_MODE]], + 'rijndael192-cbc': [['2.3.0', '3.0.2'], [FAIL_OPENSSH31_REMOVE], [WARN_CIPHER_MODE]], + 'rijndael256-cbc': [['2.3.0', '3.0.2'], [FAIL_OPENSSH31_REMOVE], [WARN_CIPHER_MODE]], + 'rijndael-cbc@lysator.liu.se': [['2.3.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_MODE]], + 'aes128-ctr': [['3.7,d0.52,l10.4.1']], + 'aes192-ctr': [['3.7,l10.4.1']], + 'aes256-ctr': [['3.7,d0.52,l10.4.1']], + 'aes128-gcm@openssh.com': [['6.2']], + 'aes256-gcm@openssh.com': [['6.2']], + 'chacha20-poly1305@openssh.com': [['6.5'], [], [], [INFO_OPENSSH69_CHACHA]], + }, + 'mac': { + 'none': [['d2013.56'], [FAIL_PLAINTEXT]], + 'hmac-sha1': [['2.1.0,d0.28,l10.2'], [], [WARN_ENCRYPT_AND_MAC, WARN_HASH_WEAK]], + 'hmac-sha1-96': [['2.5.0,d0.47', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_ENCRYPT_AND_MAC, WARN_HASH_WEAK]], + 'hmac-sha2-256': [['5.9,d2013.56,l10.7.0'], [], [WARN_ENCRYPT_AND_MAC]], + 'hmac-sha2-256-96': [['5.9', '6.0'], [FAIL_OPENSSH61_REMOVE], [WARN_ENCRYPT_AND_MAC]], + 'hmac-sha2-512': [['5.9,d2013.56,l10.7.0'], [], [WARN_ENCRYPT_AND_MAC]], + 'hmac-sha2-512-96': [['5.9', '6.0'], [FAIL_OPENSSH61_REMOVE], [WARN_ENCRYPT_AND_MAC]], + 'hmac-md5': [['2.1.0,d0.28', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_ENCRYPT_AND_MAC, WARN_HASH_WEAK]], + 'hmac-md5-96': [['2.5.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_ENCRYPT_AND_MAC, WARN_HASH_WEAK]], + 'hmac-ripemd160': [['2.5.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_ENCRYPT_AND_MAC]], + 'hmac-ripemd160@openssh.com': [['2.1.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_ENCRYPT_AND_MAC]], + 'umac-64@openssh.com': [['4.7'], [], [WARN_ENCRYPT_AND_MAC, WARN_TAG_SIZE]], + 'umac-128@openssh.com': [['6.2'], [], [WARN_ENCRYPT_AND_MAC]], + 'hmac-sha1-etm@openssh.com': [['6.2'], [], [WARN_HASH_WEAK]], + 'hmac-sha1-96-etm@openssh.com': [['6.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_HASH_WEAK]], + 'hmac-sha2-256-etm@openssh.com': [['6.2']], + 'hmac-sha2-512-etm@openssh.com': [['6.2']], + 'hmac-md5-etm@openssh.com': [['6.2', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_HASH_WEAK]], + 'hmac-md5-96-etm@openssh.com': [['6.2', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_HASH_WEAK]], + 'hmac-ripemd160-etm@openssh.com': [['6.2', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY]], + 'umac-64-etm@openssh.com': [['6.2'], [], [WARN_TAG_SIZE]], + 'umac-128-etm@openssh.com': [['6.2']], + } + } # type: Dict[str, Dict[str, List[List[Optional[str]]]]] + class KexParty(object): def __init__(self, enc, mac, compression, languages): # type: (List[text_type], List[text_type], List[text_type], List[text_type]) -> None @@ -414,7 +525,7 @@ def calc(self, v): _crc32 = None # type: Optional[SSH1.CRC32] CIPHERS = ['none', 'idea', 'des', '3des', 'tss', 'rc4', 'blowfish'] - AUTHS = [None, 'rhosts', 'rsa', 'password', 'rhosts_rsa', 'tis', 'kerberos'] + AUTHS = ['none', 'rhosts', 'rsa', 'password', 'rhosts_rsa', 'tis', 'kerberos'] @classmethod def crc32(cls, v): @@ -452,7 +563,7 @@ class KexDB(object): # pylint: disable=too-few-public-methods 'tis': [['1.2.2']], 'kerberos': [['1.2.2', '3.6'], [FAIL_OPENSSH37_REMOVE]], } - } # type: Dict[str, Dict[str, List[List[str]]]] + } # type: Dict[str, Dict[str, List[List[Optional[str]]]]] class PublicKeyMessage(object): def __init__(self, cookie, skey, hkey, pflags, cmask, amask): @@ -887,7 +998,7 @@ def _fix_date(d): @classmethod def _extract_os_version(cls, c): - # type: (Optional[str]) -> str + # type: (Optional[str]) -> Optional[str] if c is None: return None mx = re.match(r'^NetBSD(?:_Secure_Shell)?(?:[\s-]+(\d{8})(.*))?$', c) @@ -914,10 +1025,11 @@ def _extract_os_version(cls, c): @classmethod def parse(cls, banner): - # type: (SSH.Banner) -> SSH.Software + # type: (SSH.Banner) -> Optional[SSH.Software] # pylint: disable=too-many-return-statements software = str(banner.software) mx = re.match(r'^dropbear_([\d\.]+\d+)(.*)', software) + v = None # type: Optional[str] if mx: patch = cls._fix_patch(mx.group(2)) v, p = 'Matt Johnston', SSH.Product.DropbearSSH @@ -957,7 +1069,7 @@ class Banner(object): RX_BANNER = re.compile(r'^({0}(?:(?:-{0})*)){1}$'.format(_RXP, _RXR)) def __init__(self, protocol, software, comments, valid_ascii): - # type: (Tuple[int, int], str, str, bool) -> None + # type: (Tuple[int, int], Optional[str], Optional[str], bool) -> None self.__protocol = protocol self.__software = software self.__comments = comments @@ -970,12 +1082,12 @@ def protocol(self): @property def software(self): - # type: () -> str + # type: () -> Optional[str] return self.__software @property def comments(self): - # type: () -> str + # type: () -> Optional[str] return self.__comments @property @@ -1039,6 +1151,268 @@ def sha256(self): r = h.decode('ascii').rstrip('=') return u'SHA256:{0}'.format(r) + class Algorithm(object): + @staticmethod + def get_ssh_version(version_desc): + # type: (str) -> Tuple[str, str] + if version_desc.startswith('d'): + return (SSH.Product.DropbearSSH, version_desc[1:]) + elif version_desc.startswith('l1'): + return (SSH.Product.LibSSH, version_desc[2:]) + else: + return (SSH.Product.OpenSSH, version_desc) + + @classmethod + def get_timeframe(cls, versions, for_server=True, result=None): + # type: (List[Optional[str]], bool, Optional[Dict[str, List[Optional[str]]]]) -> Dict[str, List[Optional[str]]] + result = result or {} + vlen = len(versions) + for i in range(3): + if i > vlen - 1: + if i == 2 and vlen > 1: + cversions = versions[1] + else: + continue + else: + cversions = versions[i] + if cversions is None: + continue + for v in cversions.split(','): + ssh_prefix, ssh_version = cls.get_ssh_version(v) + if not ssh_version: + continue + if ssh_version.endswith('C'): + if for_server: + continue + ssh_version = ssh_version[:-1] + if ssh_prefix not in result: + result[ssh_prefix] = [None, None, None] + prev, push = result[ssh_prefix][i], False + if prev is None: + push = True + elif i == 0 and prev < ssh_version: + push = True + elif i > 0 and prev > ssh_version: + push = True + if push: + result[ssh_prefix][i] = ssh_version + return result + + @classmethod + def get_since_text(cls, versions): + # type: (List[Optional[str]]) -> Optional[text_type] + tv = [] + if len(versions) == 0 or versions[0] is None: + return None + for v in versions[0].split(','): + ssh_prefix, ssh_version = cls.get_ssh_version(v) + if not ssh_version: + continue + if ssh_prefix in [SSH.Product.LibSSH]: + continue + if ssh_version.endswith('C'): + ssh_version = '{0} (client only)'.format(ssh_version[:-1]) + tv.append('{0} {1}'.format(ssh_prefix, ssh_version)) + if len(tv) == 0: + return None + return 'available since ' + ', '.join(tv).rstrip(', ') + + class Algorithms(object): + def __init__(self, pkm, kex): + # type: (Optional[SSH1.PublicKeyMessage], Optional[SSH2.Kex]) -> None + self.__ssh1kex = pkm + self.__ssh2kex = kex + + @property + def ssh1kex(self): + # type: () -> Optional[SSH1.PublicKeyMessage] + return self.__ssh1kex + + @property + def ssh2kex(self): + # type: () -> Optional[SSH2.Kex] + return self.__ssh2kex + + @property + def ssh1(self): + # type: () -> Optional[SSH.Algorithms.Item] + if self.ssh1kex is None: + return None + item = SSH.Algorithms.Item(1, SSH1.KexDB.ALGORITHMS) + item.add('key', [u'ssh-rsa1']) + item.add('enc', self.ssh1kex.supported_ciphers) + item.add('aut', self.ssh1kex.supported_authentications) + return item + + @property + def ssh2(self): + # type: () -> Optional[SSH.Algorithms.Item] + if self.ssh2kex is None: + return None + item = SSH.Algorithms.Item(2, SSH2.KexDB.ALGORITHMS) + item.add('kex', self.ssh2kex.kex_algorithms) + item.add('key', self.ssh2kex.key_algorithms) + item.add('enc', self.ssh2kex.server.encryption) + item.add('mac', self.ssh2kex.server.mac) + return item + + @property + def values(self): + # type: () -> Iterable[SSH.Algorithms.Item] + for item in [self.ssh1, self.ssh2]: + if item is not None: + yield item + + @property + def maxlen(self): + # type: () -> int + ml, maxlen = lambda l: max(len(i) for i in l), 0 + if self.ssh1kex is not None: + maxlen = max(ml(self.ssh1kex.supported_ciphers), + ml(self.ssh1kex.supported_authentications), + maxlen) + if self.ssh2kex is not None: + maxlen = max(ml(self.ssh2kex.kex_algorithms), + ml(self.ssh2kex.key_algorithms), + ml(self.ssh2kex.server.encryption), + ml(self.ssh2kex.server.mac), + maxlen) + return maxlen + + def get_ssh_timeframe(self, for_server=True): + # type: (bool) -> Dict[str, List[Optional[str]]] + r = {} # type: Dict[str, List[Optional[str]]] + for alg_pair in self.values: + alg_db = alg_pair.db + for alg_type, alg_list in alg_pair.items(): + for alg_name in alg_list: + alg_name_native = utils.to_ntext(alg_name) + alg_desc = alg_db[alg_type].get(alg_name_native) + if alg_desc is None: + continue + versions = alg_desc[0] + r = SSH.Algorithm.get_timeframe(versions, for_server, r) + return r + + def get_recommendations(self, software, for_server=True): + # type: (Optional[SSH.Software], bool) -> Tuple[Optional[SSH.Software], Dict[int, Dict[str, Dict[str, Dict[str, int]]]]] + # pylint: disable=too-many-locals,too-many-statements + vproducts = [SSH.Product.OpenSSH, + SSH.Product.DropbearSSH, + SSH.Product.LibSSH] + if software is not None: + if software.product not in vproducts: + software = None + if software is None: + ssh_timeframe = self.get_ssh_timeframe(for_server) + for product in vproducts: + if product not in ssh_timeframe: + continue + version = ssh_timeframe[product][0] + if version is not None: + software = SSH.Software(None, product, version, None, None) + break + rec = {} # type: Dict[int, Dict[str, Dict[str, Dict[str, int]]]] + if software is None: + return software, rec + for alg_pair in self.values: + sshv, alg_db = alg_pair.sshv, alg_pair.db + rec[sshv] = {} + for alg_type, alg_list in alg_pair.items(): + if alg_type == 'aut': + continue + rec[sshv][alg_type] = {'add': {}, 'del': {}} + for n, alg_desc in alg_db[alg_type].items(): + if alg_type == 'key' and '-cert-' in n: + continue + versions = alg_desc[0] + if len(versions) == 0 or versions[0] is None: + continue + matches = False + for v in versions[0].split(','): + ssh_prefix, ssh_version = SSH.Algorithm.get_ssh_version(v) + if not ssh_version: + continue + if ssh_prefix != software.product: + continue + if ssh_version.endswith('C'): + if for_server: + continue + ssh_version = ssh_version[:-1] + if software.compare_version(ssh_version) < 0: + continue + matches = True + break + if not matches: + continue + adl, faults = len(alg_desc), 0 + for i in range(1, 3): + if not adl > i: + continue + fc = len(alg_desc[i]) + if fc > 0: + faults += pow(10, 2 - i) * fc + if n not in alg_list: + if faults > 0: + continue + rec[sshv][alg_type]['add'][n] = 0 + else: + if faults == 0: + continue + if n == 'diffie-hellman-group-exchange-sha256': + if software.compare_version('7.3') < 0: + continue + rec[sshv][alg_type]['del'][n] = faults + add_count = len(rec[sshv][alg_type]['add']) + del_count = len(rec[sshv][alg_type]['del']) + new_alg_count = len(alg_list) + add_count - del_count + if new_alg_count < 1 and del_count > 0: + mf = min(rec[sshv][alg_type]['del'].values()) + new_del = {} + for k, cf in rec[sshv][alg_type]['del'].items(): + if cf != mf: + new_del[k] = cf + if del_count != len(new_del): + rec[sshv][alg_type]['del'] = new_del + new_alg_count += del_count - len(new_del) + if new_alg_count < 1: + del rec[sshv][alg_type] + else: + if add_count == 0: + del rec[sshv][alg_type]['add'] + if del_count == 0: + del rec[sshv][alg_type]['del'] + if len(rec[sshv][alg_type]) == 0: + del rec[sshv][alg_type] + if len(rec[sshv]) == 0: + del rec[sshv] + return software, rec + + class Item(object): + def __init__(self, sshv, db): + # type: (int, Dict[str, Dict[str, List[List[Optional[str]]]]]) -> None + self.__sshv = sshv + self.__db = db + self.__storage = {} # type: Dict[str, List[text_type]] + + @property + def sshv(self): + # type: () -> int + return self.__sshv + + @property + def db(self): + # type: () -> Dict[str, Dict[str, List[List[Optional[str]]]]] + return self.__db + + def add(self, key, value): + # type: (str, List[text_type]) -> None + self.__storage[key] = value + + def items(self): + # type: () -> Iterable[Tuple[str, List[text_type]]] + return self.__storage.items() + class Security(object): # pylint: disable=too-few-public-methods # pylint: disable=bad-whitespace CVE = { @@ -1080,19 +1454,20 @@ class InsufficientReadException(Exception): SM_BANNER_SENT = 1 def __init__(self, host, port): - # type: (str, int) -> None + # type: (Optional[str], int) -> None super(SSH.Socket, self).__init__() + self.__sock = None # type: Optional[socket.socket] self.__block_size = 8 self.__state = 0 self.__header = [] # type: List[text_type] self.__banner = None # type: Optional[SSH.Banner] + if host is None: + raise ValueError('undefined host') + nport = utils.parse_int(port) + if nport < 1 or nport > 65535: + raise ValueError('invalid port: {0}'.format(port)) self.__host = host - self.__port = port - self.__sock = None # type: socket.socket - - def __enter__(self): - # type: () -> SSH.Socket - return self + self.__port = nport def _resolve(self, ipvo): # type: (Sequence[int]) -> Iterable[Tuple[int, Tuple[Any, ...]]] @@ -1142,6 +1517,8 @@ def connect(self, ipvo=(), cto=3.0, rto=5.0): def get_banner(self, sshv=2): # type: (int) -> Tuple[Optional[SSH.Banner], List[text_type], Optional[str]] + if self.__sock is None: + return self.__banner, self.__header, 'not connected' banner = 'SSH-{0}-OpenSSH_7.3'.format('1.5' if sshv == 1 else '2.0') rto = self.__sock.gettimeout() self.__sock.settimeout(0.7) @@ -1171,6 +1548,8 @@ def get_banner(self, sshv=2): def recv(self, size=2048): # type: (int) -> Tuple[int, Optional[str]] + if self.__sock is None: + return (-1, 'not connected') try: data = self.__sock.recv(size) except socket.timeout: @@ -1190,6 +1569,8 @@ def recv(self, size=2048): def send(self, data): # type: (binary_type) -> Tuple[int, Optional[str]] + if self.__sock is None: + return (-1, 'not connected') try: self.__sock.send(data) return (0, None) @@ -1278,7 +1659,7 @@ def _close_socket(self, s): try: if s is not None: s.shutdown(socket.SHUT_RDWR) - s.close() + s.close() # pragma: nocover except: # pylint: disable=bare-except pass @@ -1286,24 +1667,20 @@ def __del__(self): # type: () -> None self.__cleanup() - def __exit__(self, *args): - # type: (*Any) -> None - self.__cleanup() - def __cleanup(self): # type: () -> None self._close_socket(self.__sock) -class KexDH(object): +class KexDH(object): # pragma: nocover def __init__(self, alg, g, p): # type: (str, int, int) -> None self.__alg = alg self.__g = g self.__p = p self.__q = (self.__p - 1) // 2 - self.__x = None # type: Optional[int] - self.__e = None # type: Optional[int] + self.__x = 0 + self.__e = 0 def send_init(self, s): # type: (SSH.Socket) -> None @@ -1315,7 +1692,7 @@ def send_init(self, s): s.send_packet() -class KexGroup1(KexDH): +class KexGroup1(KexDH): # pragma: nocover def __init__(self): # type: () -> None # rfc2409: second oakley group @@ -1327,7 +1704,7 @@ def __init__(self): super(KexGroup1, self).__init__('sha1', 2, p) -class KexGroup14(KexDH): +class KexGroup14(KexDH): # pragma: nocover def __init__(self): # type: () -> None # rfc3526: 2048-bit modp group @@ -1343,316 +1720,9 @@ def __init__(self): super(KexGroup14, self).__init__('sha1', 2, p) -class KexDB(object): # pylint: disable=too-few-public-methods - # pylint: disable=bad-whitespace - WARN_OPENSSH72_LEGACY = 'disabled (in client) since OpenSSH 7.2, legacy algorithm' - FAIL_OPENSSH70_LEGACY = 'removed since OpenSSH 7.0, legacy algorithm' - FAIL_OPENSSH70_WEAK = 'removed (in server) and disabled (in client) since OpenSSH 7.0, weak algorithm' - FAIL_OPENSSH70_LOGJAM = 'disabled (in client) since OpenSSH 7.0, logjam attack' - INFO_OPENSSH69_CHACHA = 'default cipher since OpenSSH 6.9.' - FAIL_OPENSSH67_UNSAFE = 'removed (in server) since OpenSSH 6.7, unsafe algorithm' - FAIL_OPENSSH61_REMOVE = 'removed since OpenSSH 6.1, removed from specification' - FAIL_OPENSSH31_REMOVE = 'removed since OpenSSH 3.1' - FAIL_DBEAR67_DISABLED = 'disabled since Dropbear SSH 2015.67' - FAIL_DBEAR53_DISABLED = 'disabled since Dropbear SSH 0.53' - FAIL_PLAINTEXT = 'no encryption/integrity' - WARN_CURVES_WEAK = 'using weak elliptic curves' - WARN_RNDSIG_KEY = 'using weak random number generator could reveal the key' - WARN_MODULUS_SIZE = 'using small 1024-bit modulus' - WARN_MODULUS_CUSTOM = 'using custom size modulus (possibly weak)' - WARN_HASH_WEAK = 'using weak hashing algorithm' - WARN_CIPHER_MODE = 'using weak cipher mode' - WARN_BLOCK_SIZE = 'using small 64-bit block size' - WARN_CIPHER_WEAK = 'using weak cipher' - WARN_ENCRYPT_AND_MAC = 'using encrypt-and-MAC mode' - WARN_TAG_SIZE = 'using small 64-bit tag size' - - ALGORITHMS = { - 'kex': { - 'diffie-hellman-group1-sha1': [['2.3.0,d0.28,l10.2', '6.6', '6.9'], [FAIL_OPENSSH67_UNSAFE, FAIL_OPENSSH70_LOGJAM], [WARN_MODULUS_SIZE, WARN_HASH_WEAK]], - 'diffie-hellman-group14-sha1': [['3.9,d0.53,l10.6.0'], [], [WARN_HASH_WEAK]], - 'diffie-hellman-group14-sha256': [['7.3,d2016.73']], - 'diffie-hellman-group16-sha512': [['7.3,d2016.73']], - 'diffie-hellman-group18-sha512': [['7.3']], - 'diffie-hellman-group-exchange-sha1': [['2.3.0', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_HASH_WEAK]], - 'diffie-hellman-group-exchange-sha256': [['4.4'], [], [WARN_MODULUS_CUSTOM]], - 'ecdh-sha2-nistp256': [['5.7,d2013.62,l10.6.0'], [WARN_CURVES_WEAK]], - 'ecdh-sha2-nistp384': [['5.7,d2013.62'], [WARN_CURVES_WEAK]], - 'ecdh-sha2-nistp521': [['5.7,d2013.62'], [WARN_CURVES_WEAK]], - 'curve25519-sha256@libssh.org': [['6.5,d2013.62,l10.6.0']], - 'kexguess2@matt.ucc.asn.au': [['d2013.57']], - }, - 'key': { - 'rsa-sha2-256': [['7.2']], - 'rsa-sha2-512': [['7.2']], - 'ssh-ed25519': [['6.5,l10.7.0']], - 'ssh-ed25519-cert-v01@openssh.com': [['6.5']], - 'ssh-rsa': [['2.5.0,d0.28,l10.2']], - 'ssh-dss': [['2.1.0,d0.28,l10.2', '6.9'], [FAIL_OPENSSH70_WEAK], [WARN_MODULUS_SIZE, WARN_RNDSIG_KEY]], - 'ecdsa-sha2-nistp256': [['5.7,d2013.62,l10.6.4'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]], - 'ecdsa-sha2-nistp384': [['5.7,d2013.62,l10.6.4'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]], - 'ecdsa-sha2-nistp521': [['5.7,d2013.62,l10.6.4'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]], - 'ssh-rsa-cert-v00@openssh.com': [['5.4', '6.9'], [FAIL_OPENSSH70_LEGACY], []], - 'ssh-dss-cert-v00@openssh.com': [['5.4', '6.9'], [FAIL_OPENSSH70_LEGACY], [WARN_MODULUS_SIZE, WARN_RNDSIG_KEY]], - 'ssh-rsa-cert-v01@openssh.com': [['5.6']], - 'ssh-dss-cert-v01@openssh.com': [['5.6', '6.9'], [FAIL_OPENSSH70_WEAK], [WARN_MODULUS_SIZE, WARN_RNDSIG_KEY]], - 'ecdsa-sha2-nistp256-cert-v01@openssh.com': [['5.7'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]], - 'ecdsa-sha2-nistp384-cert-v01@openssh.com': [['5.7'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]], - 'ecdsa-sha2-nistp521-cert-v01@openssh.com': [['5.7'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]], - }, - 'enc': { - 'none': [['1.2.2,d2013.56,l10.2'], [FAIL_PLAINTEXT]], - '3des-cbc': [['1.2.2,d0.28,l10.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_CIPHER_WEAK, WARN_CIPHER_MODE, WARN_BLOCK_SIZE]], - '3des-ctr': [['d0.52']], - 'blowfish-cbc': [['1.2.2,d0.28,l10.2', '6.6,d0.52', '7.1,d0.52'], [FAIL_OPENSSH67_UNSAFE, FAIL_DBEAR53_DISABLED], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_MODE, WARN_BLOCK_SIZE]], - 'twofish-cbc': [['d0.28', 'd2014.66'], [FAIL_DBEAR67_DISABLED], [WARN_CIPHER_MODE]], - 'twofish128-cbc': [['d0.47', 'd2014.66'], [FAIL_DBEAR67_DISABLED], [WARN_CIPHER_MODE]], - 'twofish256-cbc': [['d0.47', 'd2014.66'], [FAIL_DBEAR67_DISABLED], [WARN_CIPHER_MODE]], - 'twofish128-ctr': [['d2015.68']], - 'twofish256-ctr': [['d2015.68']], - 'cast128-cbc': [['2.1.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_MODE, WARN_BLOCK_SIZE]], - 'arcfour': [['2.1.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_WEAK]], - 'arcfour128': [['4.2', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_WEAK]], - 'arcfour256': [['4.2', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_WEAK]], - 'aes128-cbc': [['2.3.0,d0.28,l10.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_CIPHER_MODE]], - 'aes192-cbc': [['2.3.0,l10.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_CIPHER_MODE]], - 'aes256-cbc': [['2.3.0,d0.47,l10.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_CIPHER_MODE]], - 'rijndael128-cbc': [['2.3.0', '3.0.2'], [FAIL_OPENSSH31_REMOVE], [WARN_CIPHER_MODE]], - 'rijndael192-cbc': [['2.3.0', '3.0.2'], [FAIL_OPENSSH31_REMOVE], [WARN_CIPHER_MODE]], - 'rijndael256-cbc': [['2.3.0', '3.0.2'], [FAIL_OPENSSH31_REMOVE], [WARN_CIPHER_MODE]], - 'rijndael-cbc@lysator.liu.se': [['2.3.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_MODE]], - 'aes128-ctr': [['3.7,d0.52,l10.4.1']], - 'aes192-ctr': [['3.7,l10.4.1']], - 'aes256-ctr': [['3.7,d0.52,l10.4.1']], - 'aes128-gcm@openssh.com': [['6.2']], - 'aes256-gcm@openssh.com': [['6.2']], - 'chacha20-poly1305@openssh.com': [['6.5'], [], [], [INFO_OPENSSH69_CHACHA]], - }, - 'mac': { - 'none': [['d2013.56'], [FAIL_PLAINTEXT]], - 'hmac-sha1': [['2.1.0,d0.28,l10.2'], [], [WARN_ENCRYPT_AND_MAC, WARN_HASH_WEAK]], - 'hmac-sha1-96': [['2.5.0,d0.47', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_ENCRYPT_AND_MAC, WARN_HASH_WEAK]], - 'hmac-sha2-256': [['5.9,d2013.56,l10.7.0'], [], [WARN_ENCRYPT_AND_MAC]], - 'hmac-sha2-256-96': [['5.9', '6.0'], [FAIL_OPENSSH61_REMOVE], [WARN_ENCRYPT_AND_MAC]], - 'hmac-sha2-512': [['5.9,d2013.56,l10.7.0'], [], [WARN_ENCRYPT_AND_MAC]], - 'hmac-sha2-512-96': [['5.9', '6.0'], [FAIL_OPENSSH61_REMOVE], [WARN_ENCRYPT_AND_MAC]], - 'hmac-md5': [['2.1.0,d0.28', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_ENCRYPT_AND_MAC, WARN_HASH_WEAK]], - 'hmac-md5-96': [['2.5.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_ENCRYPT_AND_MAC, WARN_HASH_WEAK]], - 'hmac-ripemd160': [['2.5.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_ENCRYPT_AND_MAC]], - 'hmac-ripemd160@openssh.com': [['2.1.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_ENCRYPT_AND_MAC]], - 'umac-64@openssh.com': [['4.7'], [], [WARN_ENCRYPT_AND_MAC, WARN_TAG_SIZE]], - 'umac-128@openssh.com': [['6.2'], [], [WARN_ENCRYPT_AND_MAC]], - 'hmac-sha1-etm@openssh.com': [['6.2'], [], [WARN_HASH_WEAK]], - 'hmac-sha1-96-etm@openssh.com': [['6.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_HASH_WEAK]], - 'hmac-sha2-256-etm@openssh.com': [['6.2']], - 'hmac-sha2-512-etm@openssh.com': [['6.2']], - 'hmac-md5-etm@openssh.com': [['6.2', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_HASH_WEAK]], - 'hmac-md5-96-etm@openssh.com': [['6.2', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_HASH_WEAK]], - 'hmac-ripemd160-etm@openssh.com': [['6.2', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY]], - 'umac-64-etm@openssh.com': [['6.2'], [], [WARN_TAG_SIZE]], - 'umac-128-etm@openssh.com': [['6.2']], - } - } # type: Dict[str, Dict[str, List[List[str]]]] - - -def get_ssh_version(version_desc): - # type: (str) -> Tuple[str, str] - if version_desc.startswith('d'): - return (SSH.Product.DropbearSSH, version_desc[1:]) - elif version_desc.startswith('l1'): - return (SSH.Product.LibSSH, version_desc[2:]) - else: - return (SSH.Product.OpenSSH, version_desc) - - -def get_alg_timeframe(versions, for_server=True, result=None): - # type: (List[str], bool, Optional[Dict[str, List[Optional[str]]]]) -> Dict[str, List[Optional[str]]] - result = result or {} - vlen = len(versions) - for i in range(3): - if i > vlen - 1: - if i == 2 and vlen > 1: - cversions = versions[1] - else: - continue - else: - cversions = versions[i] - if cversions is None: - continue - for v in cversions.split(','): - ssh_prefix, ssh_version = get_ssh_version(v) - if not ssh_version: - continue - if ssh_version.endswith('C'): - if for_server: - continue - ssh_version = ssh_version[:-1] - if ssh_prefix not in result: - result[ssh_prefix] = [None, None, None] - prev, push = result[ssh_prefix][i], False - if prev is None: - push = True - elif i == 0 and prev < ssh_version: - push = True - elif i > 0 and prev > ssh_version: - push = True - if push: - result[ssh_prefix][i] = ssh_version - return result - - -def get_ssh_timeframe(alg_pairs, for_server=True): - # type: (List[Tuple[int, Dict[str, Dict[str, List[List[str]]]], List[Tuple[str, List[text_type]]]]], bool) -> Dict[str, List[Optional[str]]] - timeframe = {} # type: Dict[str, List[Optional[str]]] - for alg_pair in alg_pairs: - alg_db = alg_pair[1] - for alg_set in alg_pair[2]: - alg_type, alg_list = alg_set - for alg_name in alg_list: - alg_name_native = utils.to_ntext(alg_name) - alg_desc = alg_db[alg_type].get(alg_name_native) - if alg_desc is None: - continue - versions = alg_desc[0] - timeframe = get_alg_timeframe(versions, for_server, timeframe) - return timeframe - - -def get_alg_since_text(versions): - # type: (List[str]) -> text_type - tv = [] - if len(versions) == 0 or versions[0] is None: - return None - for v in versions[0].split(','): - ssh_prefix, ssh_version = get_ssh_version(v) - if not ssh_version: - continue - if ssh_prefix in [SSH.Product.LibSSH]: - continue - if ssh_version.endswith('C'): - ssh_version = '{0} (client only)'.format(ssh_version[:-1]) - tv.append('{0} {1}'.format(ssh_prefix, ssh_version)) - if len(tv) == 0: - return None - return 'available since ' + ', '.join(tv).rstrip(', ') - - -def get_alg_pairs(kex, pkm): - # type: (Optional[SSH2.Kex], Optional[SSH1.PublicKeyMessage]) -> List[Tuple[int, Dict[str, Dict[str, List[List[str]]]], List[Tuple[str, List[text_type]]]]] - alg_pairs = [] - if pkm is not None: - alg_pairs.append((1, SSH1.KexDB.ALGORITHMS, - [('key', [u'ssh-rsa1']), - ('enc', pkm.supported_ciphers), - ('aut', pkm.supported_authentications)])) - if kex is not None: - alg_pairs.append((2, KexDB.ALGORITHMS, - [('kex', kex.kex_algorithms), - ('key', kex.key_algorithms), - ('enc', kex.server.encryption), - ('mac', kex.server.mac)])) - return alg_pairs - - -def get_alg_recommendations(software, kex, pkm, for_server=True): - # type: (SSH.Software, SSH2.Kex, SSH1.PublicKeyMessage, bool) -> Tuple[SSH.Software, Dict[int, Dict[str, Dict[str, Dict[str, int]]]]] - # pylint: disable=too-many-locals,too-many-statements - alg_pairs = get_alg_pairs(kex, pkm) - vproducts = [SSH.Product.OpenSSH, - SSH.Product.DropbearSSH, - SSH.Product.LibSSH] - if software is not None: - if software.product not in vproducts: - software = None - if software is None: - ssh_timeframe = get_ssh_timeframe(alg_pairs, for_server) - for product in vproducts: - if product not in ssh_timeframe: - continue - version = ssh_timeframe[product][0] - if version is not None: - software = SSH.Software(None, product, version, None, None) - break - rec = {} # type: Dict[int, Dict[str, Dict[str, Dict[str, int]]]] - if software is None: - return software, rec - for alg_pair in alg_pairs: - sshv, alg_db = alg_pair[0], alg_pair[1] - rec[sshv] = {} - for alg_set in alg_pair[2]: - alg_type, alg_list = alg_set - if alg_type == 'aut': - continue - rec[sshv][alg_type] = {'add': {}, 'del': {}} - for n, alg_desc in alg_db[alg_type].items(): - if alg_type == 'key' and '-cert-' in n: - continue - versions = alg_desc[0] - if len(versions) == 0 or versions[0] is None: - continue - matches = False - for v in versions[0].split(','): - ssh_prefix, ssh_version = get_ssh_version(v) - if not ssh_version: - continue - if ssh_prefix != software.product: - continue - if ssh_version.endswith('C'): - if for_server: - continue - ssh_version = ssh_version[:-1] - if software.compare_version(ssh_version) < 0: - continue - matches = True - break - if not matches: - continue - adl, faults = len(alg_desc), 0 - for i in range(1, 3): - if not adl > i: - continue - fc = len(alg_desc[i]) - if fc > 0: - faults += pow(10, 2 - i) * fc - if n not in alg_list: - if faults > 0: - continue - rec[sshv][alg_type]['add'][n] = 0 - else: - if faults == 0: - continue - if n == 'diffie-hellman-group-exchange-sha256': - if software.compare_version('7.3') < 0: - continue - rec[sshv][alg_type]['del'][n] = faults - add_count = len(rec[sshv][alg_type]['add']) - del_count = len(rec[sshv][alg_type]['del']) - new_alg_count = len(alg_list) + add_count - del_count - if new_alg_count < 1 and del_count > 0: - mf = min(rec[sshv][alg_type]['del'].values()) - new_del = {} - for k, cf in rec[sshv][alg_type]['del'].items(): - if cf != mf: - new_del[k] = cf - if del_count != len(new_del): - rec[sshv][alg_type]['del'] = new_del - new_alg_count += del_count - len(new_del) - if new_alg_count < 1: - del rec[sshv][alg_type] - else: - if add_count == 0: - del rec[sshv][alg_type]['add'] - if del_count == 0: - del rec[sshv][alg_type]['del'] - if len(rec[sshv][alg_type]) == 0: - del rec[sshv][alg_type] - if len(rec[sshv]) == 0: - del rec[sshv] - return software, rec - def output_algorithms(title, alg_db, alg_type, algorithms, maxlen=0): - # type: (str, Dict[str, Dict[str, List[List[str]]]], str, List[text_type], int) -> None + # type: (str, Dict[str, Dict[str, List[List[Optional[str]]]]], str, List[text_type], int) -> None with OutputBuffer() as obuf: for algorithm in algorithms: output_algorithm(alg_db, alg_type, algorithm, maxlen) @@ -1663,7 +1733,7 @@ def output_algorithms(title, alg_db, alg_type, algorithms, maxlen=0): def output_algorithm(alg_db, alg_type, alg_name, alg_max_len=0): - # type: (Dict[str, Dict[str, List[List[str]]]], str, text_type, int) -> None + # type: (Dict[str, Dict[str, List[List[Optional[str]]]]], str, text_type, int) -> None prefix = '(' + alg_type + ') ' if alg_max_len == 0: alg_max_len = len(alg_name) @@ -1678,12 +1748,14 @@ def output_algorithm(alg_db, alg_type, alg_name, alg_max_len=0): for idx, level in enumerate(['fail', 'warn', 'info']): if level == 'info': versions = alg_desc[0] - since_text = get_alg_since_text(versions) + since_text = SSH.Algorithm.get_since_text(versions) if since_text: texts.append((level, since_text)) idx = idx + 1 if ldesc > idx: for t in alg_desc[idx]: + if t is None: + continue texts.append((level, t)) if len(texts) == 0: texts.append(('info', '')) @@ -1705,22 +1777,24 @@ def output_algorithm(alg_db, alg_type, alg_name, alg_max_len=0): f(' ' * len(prefix + alg_name) + padding + ' `- ' + text) -def output_compatibility(kex, pkm, for_server=True): - # type: (Optional[SSH2.Kex], Optional[SSH1.PublicKeyMessage], bool) -> None - alg_pairs = get_alg_pairs(kex, pkm) - ssh_timeframe = get_ssh_timeframe(alg_pairs, for_server) +def output_compatibility(algs, for_server=True): + # type: (SSH.Algorithms, bool) -> None + ssh_timeframe = algs.get_ssh_timeframe(for_server) vp = 1 if for_server else 2 comp_text = [] for sshd_name in [SSH.Product.OpenSSH, SSH.Product.DropbearSSH]: if sshd_name not in ssh_timeframe: continue v = ssh_timeframe[sshd_name] + if v[0] is None: + continue if v[vp] is None: comp_text.append('{0} {1}+'.format(sshd_name, v[0])) elif v[0] == v[vp]: comp_text.append('{0} {1}'.format(sshd_name, v[0])) else: - if v[vp] < v[0]: + software = SSH.Software(None, sshd_name, v[0], None, None) + if software.compare_version(v[vp]) > 0: tfmt = '{0} {1}+ (some functionality from {2})' else: tfmt = '{0} {1}-{2}' @@ -1730,7 +1804,7 @@ def output_compatibility(kex, pkm, for_server=True): def output_security_sub(sub, software, padlen): - # type: (str, SSH.Software, int) -> None + # type: (str, Optional[SSH.Software], int) -> None secdb = SSH.Security.CVE if sub == 'cve' else SSH.Security.TXT if software is None or software.product not in secdb: return @@ -1753,9 +1827,9 @@ def output_security_sub(sub, software, padlen): def output_security(banner, padlen): - # type: (SSH.Banner, int) -> None + # type: (Optional[SSH.Banner], int) -> None with OutputBuffer() as obuf: - if banner: + if banner is not None: software = SSH.Software.parse(banner) output_security_sub('cve', software, padlen) output_security_sub('txt', software, padlen) @@ -1765,14 +1839,14 @@ def output_security(banner, padlen): out.sep() -def output_fingerprint(kex, pkm, sha256=True, padlen=0): - # type: (Optional[SSH2.Kex], Optional[SSH1.PublicKeyMessage], bool, int) -> None +def output_fingerprint(algs, sha256=True, padlen=0): + # type: (SSH.Algorithms, bool, int) -> None with OutputBuffer() as obuf: fps = [] - if pkm is not None: + if algs.ssh1kex is not None: name = 'ssh-rsa1' - fp = SSH.Fingerprint(pkm.host_key_fingerprint_data) - bits = pkm.host_key_bits + fp = SSH.Fingerprint(algs.ssh1kex.host_key_fingerprint_data) + bits = algs.ssh1kex.host_key_bits fps.append((name, fp, bits)) for fpp in fps: name, fp, bits = fpp @@ -1785,11 +1859,11 @@ def output_fingerprint(kex, pkm, sha256=True, padlen=0): out.sep() -def output_recommendations(software, kex, pkm, padlen=0): - # type: (SSH.Software, SSH2.Kex, SSH1.PublicKeyMessage, int) -> None +def output_recommendations(algs, software, padlen=0): + # type: (SSH.Algorithms, Optional[SSH.Software], int) -> None for_server = True with OutputBuffer() as obuf: - software, alg_rec = get_alg_recommendations(software, kex, pkm, for_server) + software, alg_rec = algs.get_recommendations(software, for_server) for sshv in range(2, 0, -1): if sshv not in alg_rec: continue @@ -1820,6 +1894,7 @@ def output_recommendations(software, kex, pkm, padlen=0): def output(banner, header, kex=None, pkm=None): # type: (Optional[SSH.Banner], List[text_type], Optional[SSH2.Kex], Optional[SSH1.PublicKeyMessage]) -> None sshv = 1 if pkm else 2 + algs = SSH.Algorithms(pkm, kex) with OutputBuffer() as obuf: if len(header) > 0: out.info('(gen) header: ' + '\n'.join(header)) @@ -1835,7 +1910,7 @@ def output(banner, header, kex=None, pkm=None): out.good('(gen) software: {0}'.format(software)) else: software = None - output_compatibility(kex, pkm) + output_compatibility(algs) if kex is not None: compressions = [x for x in kex.server.compression if x != 'none'] if len(compressions) > 0: @@ -1847,18 +1922,7 @@ def output(banner, header, kex=None, pkm=None): out.head('# general') obuf.flush() out.sep() - ml, maxlen = lambda l: max(len(i) for i in l), 0 - if pkm is not None: - maxlen = max(ml(pkm.supported_ciphers), - ml(pkm.supported_authentications), - maxlen) - if kex is not None: - maxlen = max(ml(kex.kex_algorithms), - ml(kex.key_algorithms), - ml(kex.server.encryption), - ml(kex.server.mac), - maxlen) - maxlen += 1 + maxlen = algs.maxlen + 1 output_security(banner, maxlen) if pkm is not None: adb = SSH1.KexDB.ALGORITHMS @@ -1871,7 +1935,7 @@ def output(banner, header, kex=None, pkm=None): title, atype = 'SSH1 authentication types', 'aut' output_algorithms(title, adb, atype, auths, maxlen) if kex is not None: - adb = KexDB.ALGORITHMS + adb = SSH2.KexDB.ALGORITHMS title, atype = 'key exchange algorithms', 'kex' output_algorithms(title, adb, atype, kex.kex_algorithms, maxlen) title, atype = 'host-key algorithms', 'key' @@ -1880,8 +1944,8 @@ def output(banner, header, kex=None, pkm=None): output_algorithms(title, adb, atype, kex.server.encryption, maxlen) title, atype = 'message authentication code algorithms', 'mac' output_algorithms(title, adb, atype, kex.server.mac, maxlen) - output_recommendations(software, kex, pkm, maxlen) - output_fingerprint(kex, pkm, True, maxlen) + output_recommendations(algs, software, maxlen) + output_fingerprint(algs, True, maxlen) class Utils(object): diff --git a/test/mypy-py2.sh b/test/mypy-py2.sh index f8e9244..766eb59 100755 --- a/test/mypy-py2.sh +++ b/test/mypy-py2.sh @@ -7,4 +7,4 @@ if [ $? -ne 0 ]; then fi _htmldir="${_cdir}/../html/mypy-py2" mkdir -p "${_htmldir}" -mypy --python-version 2.7 --config-file "${_cdir}/mypy.ini" --html-report "${_htmldir}" "${_cdir}/../ssh-audit.py" +mypy --python-version 2.7 --strict-optional --config-file "${_cdir}/mypy.ini" --html-report "${_htmldir}" "${_cdir}/../ssh-audit.py" diff --git a/test/mypy-py3.sh b/test/mypy-py3.sh index 0d2dfe5..c77ca4b 100755 --- a/test/mypy-py3.sh +++ b/test/mypy-py3.sh @@ -7,4 +7,4 @@ if [ $? -ne 0 ]; then fi _htmldir="${_cdir}/../html/mypy-py3" mkdir -p "${_htmldir}" -mypy --python-version 3.5 --config-file "${_cdir}/mypy.ini" --html-report "${_htmldir}" "${_cdir}/../ssh-audit.py" +mypy --python-version 3.5 --strict-optional --config-file "${_cdir}/mypy.ini" --html-report "${_htmldir}" "${_cdir}/../ssh-audit.py" diff --git a/test/test_socket.py b/test/test_socket.py new file mode 100644 index 0000000..d5c27fc --- /dev/null +++ b/test/test_socket.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import socket +import pytest + + +# pylint: disable=attribute-defined-outside-init +class TestSocket(object): + @pytest.fixture(autouse=True) + def init(self, ssh_audit): + self.ssh = ssh_audit.SSH + + def test_invalid_host(self, virtual_socket): + with pytest.raises(ValueError): + s = self.ssh.Socket(None, 22) + + def test_invalid_port(self, virtual_socket): + with pytest.raises(ValueError): + s = self.ssh.Socket('localhost', 'abc') + with pytest.raises(ValueError): + s = self.ssh.Socket('localhost', -1) + with pytest.raises(ValueError): + s = self.ssh.Socket('localhost', 0) + with pytest.raises(ValueError): + s = self.ssh.Socket('localhost', 65536) + + def test_not_connected_socket(self, virtual_socket): + sock = self.ssh.Socket('localhost', 22) + banner, header, err = sock.get_banner() + assert banner is None + assert len(header) == 0 + assert err == 'not connected' + s, e = sock.recv() + assert s == -1 + assert e == 'not connected' + s, e = sock.send('nothing') + assert s == -1 + assert e == 'not connected' + s, e = sock.send_packet() + assert s == -1 + assert e == 'not connected' From ff500ba84b8e008bf2bfb8a2876d96f249f19218 Mon Sep 17 00:00:00 2001 From: bs Date: Mon, 23 Jan 2017 17:45:25 +0200 Subject: [PATCH 09/45] Add OpenSSH CVE list (#25) --- ssh-audit.py | 64 ++++++++++++++++++++++++++++++++++++++++++++--- test/test_ssh1.py | 4 +-- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/ssh-audit.py b/ssh-audit.py index 0d86e03..274778f 100755 --- a/ssh-audit.py +++ b/ssh-audit.py @@ -1419,10 +1419,10 @@ class Security(object): # pylint: disable=too-few-public-methods 'Dropbear SSH': [ ['0.44', '2015.71', 1, 'CVE-2016-3116', 5.5, 'bypass command restrictions via xauth command injection'], ['0.28', '2013.58', 1, 'CVE-2013-4434', 5.0, 'discover valid usernames through different time delays'], - ['0.28', '2013.58', 1, 'CVE-2013-4421', 5.0, 'cause DoS (memory consumption) via a compressed packet'], + ['0.28', '2013.58', 1, 'CVE-2013-4421', 5.0, 'cause DoS via a compressed packet (memory consumption)'], ['0.52', '2011.54', 1, 'CVE-2012-0920', 7.1, 'execute arbitrary code or bypass command restrictions'], ['0.40', '0.48.1', 1, 'CVE-2007-1099', 7.5, 'conduct a MitM attack (no warning for hostkey mismatch)'], - ['0.28', '0.47', 1, 'CVE-2006-1206', 7.5, 'cause DoS (slot exhaustion) via large number of connections'], + ['0.28', '0.47', 1, 'CVE-2006-1206', 7.5, 'cause DoS via large number of connections (slot exhaustion)'], ['0.39', '0.47', 1, 'CVE-2006-0225', 4.6, 'execute arbitrary commands via scp with crafted filenames'], ['0.28', '0.46', 1, 'CVE-2005-4178', 6.5, 'execute arbitrary code via buffer overflow vulnerability'], ['0.28', '0.42', 1, 'CVE-2004-2486', 7.5, 'execute arbitrary code via DSS verification code']], @@ -1436,7 +1436,65 @@ class Security(object): # pylint: disable=too-few-public-methods ['0.4.7', '0.5.2', 1, 'CVE-2012-4562', 7.5, 'cause DoS or execute arbitrary code (overflow check)'], ['0.4.7', '0.5.2', 1, 'CVE-2012-4561', 5.0, 'cause DoS via unspecified vectors (invalid pointer)'], ['0.4.7', '0.5.2', 1, 'CVE-2012-4560', 7.5, 'cause DoS or execute arbitrary code (buffer overflow)'], - ['0.4.7', '0.5.2', 1, 'CVE-2012-4559', 6.8, 'cause DoS or execute arbitrary code (double free)']] + ['0.4.7', '0.5.2', 1, 'CVE-2012-4559', 6.8, 'cause DoS or execute arbitrary code (double free)']], + 'OpenSSH': [ + ['7.2', '7.2p2', 1, 'CVE-2016-6515', 7.8, 'cause DoS via long password string (crypt CPU consumption)'], + ['1.2.2', '7.2', 1, 'CVE-2016-3115', 5.5, 'bypass command restrictions via crafted X11 forwarding data'], + ['5.4', '7.1', 1, 'CVE-2016-1907', 5.0, 'cause DoS via crafted network traffic (out of bounds read)'], + ['5.4', '7.1p1', 2, 'CVE-2016-0778', 4.6, 'cause DoS via requesting many forwardings (heap based buffer overflow)'], + ['5.0', '7.1p1', 2, 'CVE-2016-0777', 4.0, 'leak data via allowing transfer of entire buffer'], + ['6.0', '7.2p2', 5, 'CVE-2015-8325', 7.2, 'privilege escalation via triggering crafted environment'], + ['6.8', '6.9', 5, 'CVE-2015-6565', 7.2, 'cause DoS via writing to a device (terminal disruption)'], + ['5.0', '6.9', 5, 'CVE-2015-6564', 6.9, 'privilege escalation via leveraging sshd uid'], + ['5.0', '6.9', 5, 'CVE-2015-6563', 1.9, 'conduct impersonation attack'], + ['6.9p1', '6.9p1', 1, 'CVE-2015-5600', 8.5, 'cause Dos or aid in conduct brute force attack (CPU consumption)'], + ['6.0', '6.6', 1, 'CVE-2015-5352', 4.3, 'bypass access restrictions via a specific connection'], + ['6.0', '6.6', 2, 'CVE-2014-2653', 5.8, 'bypass SSHFP DNS RR check via unacceptable host certificate'], + ['5.0', '6.5', 1, 'CVE-2014-2532', 5.8, 'bypass environment restrictions via specific string before wildcard'], + ['1.2', '6.4', 1, 'CVE-2014-1692', 7.5, 'cause DoS via triggering error condition (memory corruption)'], + ['6.2', '6.3', 1, 'CVE-2013-4548', 6.0, 'bypass command restrictions via crafted packet data'], + ['1.2', '5.6', 1, 'CVE-2012-0814', 3.5, 'leak data via debug messages'], + ['1.2', '5.8', 1, 'CVE-2011-5000', 3.5, 'cause DoS via large value in certain length field (memory consumption)'], + ['5.6', '5.7', 2, 'CVE-2011-0539', 5.0, 'leak data or conduct hash collision attack'], + ['1.2', '6.1', 1, 'CVE-2010-5107', 5.0, 'cause DoS via large number of connections (slot exhaustion)'], + ['1.2', '5.8', 1, 'CVE-2010-4755', 4.0, 'cause DoS via crafted glob expression (CPU and memory consumption)'], + ['1.2', '5.6', 1, 'CVE-2010-4478', 7.5, 'bypass authentication check via crafted values'], + ['4.3', '4.8', 1, 'CVE-2009-2904', 6.9, 'privilege escalation via hard links to setuid programs'], + ['4.0', '5.1', 1, 'CVE-2008-5161', 2.6, 'recover plaintext data from ciphertext'], + ['1.2', '4.6', 1, 'CVE-2008-4109', 5.0, 'cause DoS via multiple login attempts (slot exhaustion)'], + ['1.2', '4.8', 1, 'CVE-2008-1657', 6.5, 'bypass command restrictions via modifying session file'], + ['1.2.2', '4.9', 1, 'CVE-2008-1483', 6.9, 'hijack forwarded X11 connections'], + ['4.0', '4.6', 1, 'CVE-2007-4752', 7.5, 'privilege escalation via causing an X client to be trusted'], + ['4.3p2', '4.3p2', 1, 'CVE-2007-3102', 4.3, 'allow attacker to write random data to audit log'], + ['1.2', '4.6', 1, 'CVE-2007-2243', 5.0, 'discover valid usernames through different responses'], + ['4.4', '4.4', 1, 'CVE-2006-5794', 7.5, 'bypass authentication'], + ['4.1', '4.1p1', 1, 'CVE-2006-5229', 2.6, 'discover valid usernames through different time delays'], + ['1.2', '4.3p2', 1, 'CVE-2006-5052', 5.0, 'discover valid usernames through different responses'], + ['1.2', '4.3p2', 1, 'CVE-2006-5051', 9.3, 'cause DoS or execute arbitrary code (double free)'], + ['4.5', '4.5', 1, 'CVE-2006-4925', 5.0, 'cause DoS via invalid protocol sequence (crash)'], + ['1.2', '4.3p2', 1, 'CVE-2006-4924', 7.8, 'cause DoS via crafted packet (CPU consumption)'], + ['3.8.1p1', '3.8.1p1', 1, 'CVE-2006-0883', 5.0, 'cause DoS via connecting multiple times (client connection refusal)'], + ['3.0', '4.2p1', 1, 'CVE-2006-0225', 4.6, 'execute arbitrary code'], + ['2.1', '4.1p1', 1, 'CVE-2005-2798', 5.0, 'leak data about authentication credentials'], + ['3.5', '3.5p1', 1, 'CVE-2004-2760', 6.8, 'leak data through different connection states'], + ['2.3', '3.7.1p2', 1, 'CVE-2004-2069', 5.0, 'cause DoS via large number of connections (slot exhaustion)'], + ['3.0', '3.4p1', 1, 'CVE-2004-0175', 4.3, 'leak data through directoy traversal'], + ['1.2', '3.9p1', 1, 'CVE-2003-1562', 7.6, 'leak data about authentication credentials'], + ['3.1p1', '3.7.1p1', 1, 'CVE-2003-0787', 7.5, 'privilege escalation via modifying stack'], + ['3.1p1', '3.7.1p1', 1, 'CVE-2003-0786', 10.0,'privilege escalation via bypassing authentication'], + ['1.0', '3.7.1', 1, 'CVE-2003-0695', 7.5, 'cause DoS or execute arbitrary code'], + ['1.0', '3.7', 1, 'CVE-2003-0693', 10.0,'execute arbitrary code'], + ['3.0', '3.6.1p2', 1, 'CVE-2003-0386', 7.5, 'bypass address restrictions for connection'], + ['3.1p1', '3.6.1p1', 1, 'CVE-2003-0190', 5.0, 'discover valid usernames through different time delays'], + ['3.2.2', '3.2.2', 1, 'CVE-2002-0765', 7.5, 'bypass authentication'], + ['1.2.2', '3.3p1', 1, 'CVE-2002-0640', 10.0,'execute arbitrary code'], + ['1.2.2', '3.3p1', 1, 'CVE-2002-0639', 10.0,'execute arbitrary code'], + ['2.1', '3.2', 1, 'CVE-2002-0575', 7.5, 'privilege escalation'], + ['2.1', '3.0.2p1', 2, 'CVE-2002-0083', 10.0,'privilege escalation'], + ['3.0', '3.0p1', 1, 'CVE-2001-1507', 7.5, 'bypass authentication'], + ['1.2.3', '3.0.1p1', 5, 'CVE-2001-0872', 7.2, 'privilege escalation via crafted environment variables'], + ['1.2.3', '2.1.1', 1, 'CVE-2001-0361', 4.0, 'recover plaintext from ciphertext'], + ['1.2', '2.1', 1, 'CVE-2000-0525', 10.0,'execute arbitrary code (improper privileges)']] } # type: Dict[str, List[List[Any]]] TXT = { 'Dropbear SSH': [ diff --git a/test/test_ssh1.py b/test/test_ssh1.py index 0029845..0f62983 100644 --- a/test/test_ssh1.py +++ b/test/test_ssh1.py @@ -108,7 +108,7 @@ def test_ssh1_server_simple(self, output_spy, virtual_socket): output_spy.begin() self.audit(self._conf()) lines = output_spy.flush() - assert len(lines) == 10 + assert len(lines) == 13 def test_ssh1_server_invalid_first_packet(self, output_spy, virtual_socket): vsocket = virtual_socket @@ -121,7 +121,7 @@ def test_ssh1_server_invalid_first_packet(self, output_spy, virtual_socket): with pytest.raises(SystemExit): self.audit(self._conf()) lines = output_spy.flush() - assert len(lines) == 4 + assert len(lines) == 7 assert 'unknown message' in lines[-1] def test_ssh1_server_invalid_checksum(self, output_spy, virtual_socket): From c9443e6e0697208025e6736cf47659ecb3fbb896 Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Mon, 23 Jan 2017 19:20:42 +0200 Subject: [PATCH 10/45] Fix pyp3 version for Travis-CI (https://github.com/travis-ci/travis-ci/issues/6277). --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index f1ee663..4832d8d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,7 @@ python: - 3.4 - 3.5 - pypy - - pypy3 + - pypy3.3-5.2-alpha1 install: - pip install --upgrade pytest - pip install --upgrade pytest-cov From 54b09605024e8109582cf3e8d38e4b91cd5e50ee Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Mon, 23 Jan 2017 19:34:06 +0200 Subject: [PATCH 11/45] Upgrade to Mypy 0.470. Add colorama stub. Fix identation. --- ssh-audit.py | 17 +++++++++-------- test/mypy-py2.sh | 2 +- test/mypy-py3.sh | 2 +- test/mypy.ini | 4 ++-- test/stubs/colorama.pyi | 6 ++++++ 5 files changed, 19 insertions(+), 12 deletions(-) create mode 100644 test/stubs/colorama.pyi diff --git a/ssh-audit.py b/ssh-audit.py index 274778f..e406567 100755 --- a/ssh-audit.py +++ b/ssh-audit.py @@ -1267,16 +1267,17 @@ def values(self): def maxlen(self): # type: () -> int ml, maxlen = lambda l: max(len(i) for i in l), 0 + # type: Callable[[Sequence[text_type]], int], int if self.ssh1kex is not None: maxlen = max(ml(self.ssh1kex.supported_ciphers), - ml(self.ssh1kex.supported_authentications), - maxlen) + ml(self.ssh1kex.supported_authentications), + maxlen) if self.ssh2kex is not None: maxlen = max(ml(self.ssh2kex.kex_algorithms), - ml(self.ssh2kex.key_algorithms), - ml(self.ssh2kex.server.encryption), - ml(self.ssh2kex.server.mac), - maxlen) + ml(self.ssh2kex.key_algorithms), + ml(self.ssh2kex.server.encryption), + ml(self.ssh2kex.server.mac), + maxlen) return maxlen def get_ssh_timeframe(self, for_server=True): @@ -1298,8 +1299,8 @@ def get_recommendations(self, software, for_server=True): # type: (Optional[SSH.Software], bool) -> Tuple[Optional[SSH.Software], Dict[int, Dict[str, Dict[str, Dict[str, int]]]]] # pylint: disable=too-many-locals,too-many-statements vproducts = [SSH.Product.OpenSSH, - SSH.Product.DropbearSSH, - SSH.Product.LibSSH] + SSH.Product.DropbearSSH, + SSH.Product.LibSSH] if software is not None: if software.product not in vproducts: software = None diff --git a/test/mypy-py2.sh b/test/mypy-py2.sh index 766eb59..2da29f5 100755 --- a/test/mypy-py2.sh +++ b/test/mypy-py2.sh @@ -7,4 +7,4 @@ if [ $? -ne 0 ]; then fi _htmldir="${_cdir}/../html/mypy-py2" mkdir -p "${_htmldir}" -mypy --python-version 2.7 --strict-optional --config-file "${_cdir}/mypy.ini" --html-report "${_htmldir}" "${_cdir}/../ssh-audit.py" +env MYPYPATH="${_cdir}/stubs/" mypy --python-version 2.7 --strict-optional --config-file "${_cdir}/mypy.ini" --html-report "${_htmldir}" "${_cdir}/../ssh-audit.py" diff --git a/test/mypy-py3.sh b/test/mypy-py3.sh index c77ca4b..0b7f27c 100755 --- a/test/mypy-py3.sh +++ b/test/mypy-py3.sh @@ -7,4 +7,4 @@ if [ $? -ne 0 ]; then fi _htmldir="${_cdir}/../html/mypy-py3" mkdir -p "${_htmldir}" -mypy --python-version 3.5 --strict-optional --config-file "${_cdir}/mypy.ini" --html-report "${_htmldir}" "${_cdir}/../ssh-audit.py" +env MYPYPATH="${_cdir}/stubs/" mypy --python-version 3.5 --strict-optional --config-file "${_cdir}/mypy.ini" --html-report "${_htmldir}" "${_cdir}/../ssh-audit.py" diff --git a/test/mypy.ini b/test/mypy.ini index 9c0a3e0..f4190cd 100644 --- a/test/mypy.ini +++ b/test/mypy.ini @@ -1,9 +1,9 @@ [mypy] -silent_imports = True +ignore_missing_imports = False +follow_imports = error disallow_untyped_calls = True disallow_untyped_defs = True check_untyped_defs = True disallow-subclassing-any = True warn-incomplete-stub = True warn-redundant-casts = True - diff --git a/test/stubs/colorama.pyi b/test/stubs/colorama.pyi new file mode 100644 index 0000000..81d6ef0 --- /dev/null +++ b/test/stubs/colorama.pyi @@ -0,0 +1,6 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from typing import Optional + +def init(autoreset: bool = False, convert: Optional[bool] = None, strip: Optional[bool] = None, wrap: bool = True) -> None: ... + From 9ac03d368a3fcbc1629a239f9d023ae4585b8f14 Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Tue, 24 Jan 2017 12:45:53 +0200 Subject: [PATCH 12/45] Add OpenSSH 7.4 changes and use as default banner. --- ssh-audit.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ssh-audit.py b/ssh-audit.py index e406567..6087761 100755 --- a/ssh-audit.py +++ b/ssh-audit.py @@ -269,6 +269,7 @@ def __exit__(self, *args): class SSH2(object): # pylint: disable=too-few-public-methods class KexDB(object): # pylint: disable=too-few-public-methods # pylint: disable=bad-whitespace + WARN_OPENSSH74_UNSAFE = 'disabled (in client) since OpenSSH 7.4, unsafe algorithm' WARN_OPENSSH72_LEGACY = 'disabled (in client) since OpenSSH 7.2, legacy algorithm' FAIL_OPENSSH70_LEGACY = 'removed since OpenSSH 7.0, legacy algorithm' FAIL_OPENSSH70_WEAK = 'removed (in server) and disabled (in client) since OpenSSH 7.0, weak algorithm' @@ -304,6 +305,7 @@ class KexDB(object): # pylint: disable=too-few-public-methods 'ecdh-sha2-nistp384': [['5.7,d2013.62'], [WARN_CURVES_WEAK]], 'ecdh-sha2-nistp521': [['5.7,d2013.62'], [WARN_CURVES_WEAK]], 'curve25519-sha256@libssh.org': [['6.5,d2013.62,l10.6.0']], + 'curve25519-sha256': [['7.4']], 'kexguess2@matt.ucc.asn.au': [['d2013.57']], }, 'key': { @@ -326,7 +328,7 @@ class KexDB(object): # pylint: disable=too-few-public-methods }, 'enc': { 'none': [['1.2.2,d2013.56,l10.2'], [FAIL_PLAINTEXT]], - '3des-cbc': [['1.2.2,d0.28,l10.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_CIPHER_WEAK, WARN_CIPHER_MODE, WARN_BLOCK_SIZE]], + '3des-cbc': [['1.2.2,d0.28,l10.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH74_UNSAFE, WARN_CIPHER_WEAK, WARN_CIPHER_MODE, WARN_BLOCK_SIZE]], '3des-ctr': [['d0.52']], 'blowfish-cbc': [['1.2.2,d0.28,l10.2', '6.6,d0.52', '7.1,d0.52'], [FAIL_OPENSSH67_UNSAFE, FAIL_DBEAR53_DISABLED], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_MODE, WARN_BLOCK_SIZE]], 'twofish-cbc': [['d0.28', 'd2014.66'], [FAIL_DBEAR67_DISABLED], [WARN_CIPHER_MODE]], @@ -1578,7 +1580,7 @@ def get_banner(self, sshv=2): # type: (int) -> Tuple[Optional[SSH.Banner], List[text_type], Optional[str]] if self.__sock is None: return self.__banner, self.__header, 'not connected' - banner = 'SSH-{0}-OpenSSH_7.3'.format('1.5' if sshv == 1 else '2.0') + banner = 'SSH-{0}-OpenSSH_7.4'.format('1.5' if sshv == 1 else '2.0') rto = self.__sock.gettimeout() self.__sock.settimeout(0.7) s, e = self.recv() From 94a74e9cfda22ca9edd2cbb569ad47d7dacbd0c7 Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Mon, 13 Feb 2017 13:33:50 +0200 Subject: [PATCH 13/45] Reviewed libssh-0.7.4 changes. --- test/test_software.py | 8 ++++---- test/test_version_compare.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/test/test_software.py b/test/test_software.py index 141ffec..4785041 100644 --- a/test/test_software.py +++ b/test/test_software.py @@ -168,17 +168,17 @@ def test_libssh_software(self): assert s.display(True) == str(s) assert s.display(False) == str(s) assert repr(s) == '' - s = ps('SSH-2.0-libssh-0.7.3') + s = ps('SSH-2.0-libssh-0.7.4') assert s.vendor is None assert s.product == 'libssh' - assert s.version == '0.7.3' + assert s.version == '0.7.4' assert s.patch is None assert s.os is None - assert str(s) == 'libssh 0.7.3' + assert str(s) == 'libssh 0.7.4' assert str(s) == s.display() assert s.display(True) == str(s) assert s.display(False) == str(s) - assert repr(s) == '' + assert repr(s) == '' def test_romsshell_software(self): ps = lambda x: self.ssh.Software.parse(self.ssh.Banner.parse(x)) # noqa diff --git a/test/test_version_compare.py b/test/test_version_compare.py index d3f8554..b5c4a1f 100644 --- a/test/test_version_compare.py +++ b/test/test_version_compare.py @@ -200,7 +200,7 @@ def test_libssh_compare_version_sequential(self): versions.append('0.5.{0}'.format(i)) for i in range(0, 6): versions.append('0.6.{0}'.format(i)) - for i in range(0, 4): + for i in range(0, 5): versions.append('0.7.{0}'.format(i)) l = len(versions) for i in range(l): From 65ef250aaee69c916ddba71b24ad4eb48436a722 Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Thu, 23 Mar 2017 23:17:35 +0200 Subject: [PATCH 14/45] Upgrade to Mypy 0.501 and fix issues. Add requirements.txt. --- ssh-audit.py | 18 +++++++++++------- test/mypy-py2.sh | 7 ++++++- test/mypy-py3.sh | 6 +++++- test/mypy.ini | 10 +++++++--- test/requirements-py2.txt | 5 +++++ test/requirements-py3.txt | 6 ++++++ 6 files changed, 40 insertions(+), 12 deletions(-) create mode 100644 test/requirements-py2.txt create mode 100644 test/requirements-py3.txt diff --git a/ssh-audit.py b/ssh-audit.py index 6087761..b482467 100755 --- a/ssh-audit.py +++ b/ssh-audit.py @@ -39,7 +39,7 @@ binary_type = str try: # pragma: nocover # pylint: disable=unused-import - from typing import List, Set, Sequence, Tuple, Iterable + from typing import Dict, List, Set, Sequence, Tuple, Iterable from typing import Callable, Optional, Union, Any except ImportError: # pragma: nocover pass @@ -713,7 +713,8 @@ def read(self, size): def read_byte(self): # type: () -> int - return struct.unpack('B', self.read(1))[0] + v = struct.unpack('B', self.read(1))[0] # type: int + return v def read_bool(self): # type: () -> bool @@ -721,7 +722,8 @@ def read_bool(self): def read_int(self): # type: () -> int - return struct.unpack('>I', self.read(4))[0] + v = struct.unpack('>I', self.read(4))[0] # type: int + return v def read_list(self): # type: () -> List[text_type] @@ -1268,8 +1270,10 @@ def values(self): @property def maxlen(self): # type: () -> int - ml, maxlen = lambda l: max(len(i) for i in l), 0 - # type: Callable[[Sequence[text_type]], int], int + maxlen = 0 + def ml(items): + # type: (Sequence[text_type]) -> int + return max(len(i) for i in items) if self.ssh1kex is not None: maxlen = max(ml(self.ssh1kex.supported_ciphers), ml(self.ssh1kex.supported_authentications), @@ -1536,8 +1540,8 @@ def _resolve(self, ipvo): ipvo_len = len(ipvo) prefer_ipvo = ipvo_len > 0 prefer_ipv4 = prefer_ipvo and ipvo[0] == 4 - if len(ipvo) == 1: - family = {4: socket.AF_INET, 6: socket.AF_INET6}.get(ipvo[0]) + if ipvo_len == 1: + family = socket.AF_INET if ipvo[0] == 4 else socket.AF_INET6 else: family = socket.AF_UNSPEC try: diff --git a/test/mypy-py2.sh b/test/mypy-py2.sh index 2da29f5..17ba99e 100755 --- a/test/mypy-py2.sh +++ b/test/mypy-py2.sh @@ -7,4 +7,9 @@ if [ $? -ne 0 ]; then fi _htmldir="${_cdir}/../html/mypy-py2" mkdir -p "${_htmldir}" -env MYPYPATH="${_cdir}/stubs/" mypy --python-version 2.7 --strict-optional --config-file "${_cdir}/mypy.ini" --html-report "${_htmldir}" "${_cdir}/../ssh-audit.py" +env MYPYPATH="${_cdir}/stubs/" mypy \ +--python-version 2.7 \ +--no-warn-incomplete-stub \ +--show-error-context \ +--config-file "${_cdir}/mypy.ini" \ +--html-report "${_htmldir}" "${_cdir}/../ssh-audit.py" diff --git a/test/mypy-py3.sh b/test/mypy-py3.sh index 0b7f27c..81f2421 100755 --- a/test/mypy-py3.sh +++ b/test/mypy-py3.sh @@ -7,4 +7,8 @@ if [ $? -ne 0 ]; then fi _htmldir="${_cdir}/../html/mypy-py3" mkdir -p "${_htmldir}" -env MYPYPATH="${_cdir}/stubs/" mypy --python-version 3.5 --strict-optional --config-file "${_cdir}/mypy.ini" --html-report "${_htmldir}" "${_cdir}/../ssh-audit.py" +env MYPYPATH="${_cdir}/stubs/" mypy \ +--python-version 3.5 \ +--show-error-context \ +--config-file "${_cdir}/mypy.ini" \ +--html-report "${_htmldir}" "${_cdir}/../ssh-audit.py" diff --git a/test/mypy.ini b/test/mypy.ini index f4190cd..128ac30 100644 --- a/test/mypy.ini +++ b/test/mypy.ini @@ -4,6 +4,10 @@ follow_imports = error disallow_untyped_calls = True disallow_untyped_defs = True check_untyped_defs = True -disallow-subclassing-any = True -warn-incomplete-stub = True -warn-redundant-casts = True +disallow_subclassing_any = True +warn_incomplete_stub = True +warn_redundant_casts = True +warn_return_any = True +warn_unused_ignores = True +strict_optional = True +#strict_boolean = False diff --git a/test/requirements-py2.txt b/test/requirements-py2.txt new file mode 100644 index 0000000..bd50c11 --- /dev/null +++ b/test/requirements-py2.txt @@ -0,0 +1,5 @@ +pytest==3.0.7 +pytest-cov==2.4.0 +lxml==3.7.3 +colorama==0.3.7 +prospector==0.12.4 diff --git a/test/requirements-py3.txt b/test/requirements-py3.txt new file mode 100644 index 0000000..9957de7 --- /dev/null +++ b/test/requirements-py3.txt @@ -0,0 +1,6 @@ +pytest==3.0.7 +pytest-cov==2.4.0 +mypy==0.501 +lxml==3.7.3 +colorama==0.3.7 +prospector==0.12.4 From d3ba5a4e6ffa3aebcac4055a86f08d6f22c67d2b Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Sat, 25 Mar 2017 06:00:42 +0200 Subject: [PATCH 15/45] Use tox, use codecov, work around pypy3 issues. --- .gitignore | 4 +- .travis.yml | 62 +++++++++++++++----- tox.ini | 161 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 213 insertions(+), 14 deletions(-) create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore index 481cc4a..8a442a4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ *.pyc html/ venv/ -.cache/ \ No newline at end of file +.cache/ +.tox +.coverage diff --git a/.travis.yml b/.travis.yml index 4832d8d..0722577 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,18 +1,54 @@ language: python -python: - - 2.6 - - 2.7 - - 3.3 - - 3.4 - - 3.5 - - pypy - - pypy3.3-5.2-alpha1 +matrix: + include: + - python: 2.6 + env: TOXENV=py26 + - python: 2.7 + env: TOXENV=py27 + - python: 3.3 + env: TOXENV=py33 + - python: 3.4 + env: TOXENV=py34 + - python: 3.5 + env: TOXENV=py35 + - python: 3.6 + env: TOXENV=py36 + - python: 3.7-dev + env: TOXENV=py37 + - python: nightly + env: TOXENV=py37 + - python: pypy + env: TOXENV=pypy + - python: pypy3 + env: TOXENV=pypy3 + - python: pypy3.3-5.2-alpha1 + env: TOXENV=pypy3 + - python: pypy3.3-5.5-alpha + env: TOXENV=pypy3 + allow_failures: + - python: pypy3 + - python: 3.7-dev + - python: nightly install: - - pip install --upgrade pytest - - pip install --upgrade pytest-cov - - pip install --upgrade coveralls + - pip install --upgrade tox coveralls codecov script: - - py.test --cov-report= --cov=ssh-audit -v test + - if [ -z "${TOXENV##*py3*}" ]; then + export MYPYBASE=python; + if [ -z "${TOXENV##*pypy3*}" ]; then + _pydir=$(dirname $(which python)); + ln -s -- "${_pydir}/python" "${_pydir}/pypy3"; + export TOXENV=${TOXENV},cov,lint; + else + export TOXENV=${TOXENV},cov,type,lint; + fi + else + export MYPYBASE=python-unknown; + export TOXENV=${TOXENV},cov,lint; + fi + - tox -e $TOXENV after_success: - coveralls - + - codecov +after_failure: + - cat .tox/log/* + - cat .tox/*/log/* diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..b5e5acc --- /dev/null +++ b/tox.ini @@ -0,0 +1,161 @@ +[tox] +envlist = py26,py27,py33,py34,py35,py36,py37,jython,pypy,pypy3,cov,type,lint +skipsdist = true +skip_missing_interpreters = true + +[testenv] +deps = + pytest==3.0.7 + coverage==4.3.4 + colorama==0.3.7 +setenv = + SSHAUDIT = {toxinidir}/ssh-audit.py + COVERAGE_FILE = {toxinidir}/.coverage.{envname} +commands = + coverage run --source ssh-audit -m -- pytest -v {posargs:test} + coverage report --show-missing + coverage html -d {toxinidir}/html/coverage.{envname} + +[testenv:cov] +deps = + coverage==4.3.4 +setenv = + COVERAGE_FILE = {toxinidir}/.coverage +commands = + coverage erase + coverage combine + coverage report --show-missing + coverage html -d {toxinidir}/html/coverage +ignore_outcome = true + +[testenv:mypy] +basepython = + {env:MYPYBASE:python3.5} +deps = + colorama==0.3.7 + mypy==0.501 + lxml==3.7.3 +setenv = + {[testenv]setenv} + MYPYPATH = {toxinidir}/test/stubs + MYPYHTML = {toxinidir}/html/mypy +commands = + mypy \ + --show-error-context \ + --config-file {toxinidir}/tox.ini \ + --html-report {env:MYPYHTML}.py3 \ + {posargs:{env:SSHAUDIT}} + mypy \ + -2 \ + --no-warn-incomplete-stub \ + --show-error-context \ + --config-file {toxinidir}/tox.ini \ + --html-report {env:MYPYHTML}.py2 \ + {posargs:{env:SSHAUDIT}} + +[testenv:pylint] +deps = + mccabe + pylint +commands = + pylint \ + --rcfile tox.ini \ + --load-plugins=pylint.extensions.bad_builtin \ + --load-plugins=pylint.extensions.check_elif \ + --load-plugins=pylint.extensions.mccabe \ + {posargs:{env:SSHAUDIT}} + +[testenv:flake8] +deps = + flake8 +commands = + flake8 {posargs:{env:SSHAUDIT}} + +[testenv:vulture] +deps = vulture +commands = + python -c "import sys; from subprocess import Popen, PIPE; \ + a = ['vulture'] + r'{posargs:{env:SSHAUDIT}}'.split(' '); \ + o = Popen(a, shell=False, stdout=PIPE).communicate()[0]; \ + l = [x for x in o.split('\n') if x and 'Unused import' not in x]; \ + print('\n'.join(l)); \ + sys.exit(1 if len(l) > 0 else 0)" + +[testenv:type] +basepython = + {[testenv:mypy]basepython} +deps = + {[testenv:mypy]deps} +setenv = + {[testenv:mypy]setenv} +commands = + {[testenv:mypy]commands} +ignore_outcome = true + +[testenv:lint] +deps = + {[testenv:pylint]deps} + {[testenv:flake8]deps} + {[testenv:vulture]deps} +commands = + {[testenv:pylint]commands} + {[testenv:flake8]commands} + {[testenv:vulture]commands} +ignore_outcome = true + + +[mypy] +ignore_missing_imports = False +follow_imports = error +disallow_untyped_calls = True +disallow_untyped_defs = True +check_untyped_defs = True +disallow_subclassing_any = True +warn_incomplete_stub = True +warn_redundant_casts = True +warn_return_any = True +warn_unused_ignores = True +strict_optional = True +#strict_boolean = False + +[pylint] +reports = no +#output-format = colorized +indent-string = \t +disable = locally-disabled, bad-continuation, multiple-imports, invalid-name, trailing-whitespace, missing-docstring +max-complexity = 15 +max-args = 8 +max-locals = 20 +max-returns = 6 +max-branches = 15 +max-statements = 60 +max-parents = 7 +max-attributes = 8 +min-public-methods = 1 +max-public-methods = 20 +max-bool-expr = 5 +max-nested-blocks = 6 +max-line-length = 80 +ignore-long-lines = ^\s*(#\s+type:\s+.*|[A-Z0-9_]+\s+=\s+.*|('.*':\s+)?\[.*\],?)$ +max-module-lines = 2500 + +[flake8] +ignore = + # indentation contains tabs + W191, + # blank line contains whitespace + W293, + # indentation contains mixed spaces and tabs + E101, + # multiple spaces before operator + E221, + # multiple spaces after operator + E241, + # multiple imports on one line + E401, + # line too long + E501, + # module imported but unused + F401, + # undefined name + F821 From 8b7659c4d3d58b1446d0b6b368858998626e30da Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Sat, 25 Mar 2017 08:02:49 +0200 Subject: [PATCH 16/45] Remove unnecessary files, now that everything is in tox. Add codecov badge. --- .gitignore | 1 + README.md | 1 + test/coverage.sh | 10 ---------- test/mypy-py2.sh | 15 -------------- test/mypy-py3.sh | 14 ------------- test/mypy.ini | 13 ------------ test/prospector.sh | 13 ------------ test/prospector.yml | 42 --------------------------------------- test/requirements-py2.txt | 5 ----- test/requirements-py3.txt | 6 ------ 10 files changed, 2 insertions(+), 118 deletions(-) delete mode 100755 test/coverage.sh delete mode 100755 test/mypy-py2.sh delete mode 100755 test/mypy-py3.sh delete mode 100644 test/mypy.ini delete mode 100755 test/prospector.sh delete mode 100644 test/prospector.yml delete mode 100644 test/requirements-py2.txt delete mode 100644 test/requirements-py3.txt diff --git a/.gitignore b/.gitignore index 8a442a4..0edf4f4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ venv/ .cache/ .tox .coverage +coverage.xml diff --git a/README.md b/README.md index e9f8f13..c78d779 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # ssh-audit [![build status](https://api.travis-ci.org/arthepsy/ssh-audit.svg)](https://travis-ci.org/arthepsy/ssh-audit) +[![codecov](https://codecov.io/gh/arthepsy/ssh-audit/branch/develop/graph/badge.svg)](https://codecov.io/gh/arthepsy/ssh-audit) [![coverage status](https://coveralls.io/repos/github/arthepsy/ssh-audit/badge.svg)](https://coveralls.io/github/arthepsy/ssh-audit) **ssh-audit** is a tool for ssh server auditing. diff --git a/test/coverage.sh b/test/coverage.sh deleted file mode 100755 index 28f2010..0000000 --- a/test/coverage.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh -_cdir=$(cd -- "$(dirname "$0")" && pwd) -type py.test > /dev/null 2>&1 -if [ $? -ne 0 ]; then - echo "err: py.test (Python testing framework) not found." - exit 1 -fi -cd -- "${_cdir}/.." -mkdir -p html -py.test -v --cov-report=html:html/coverage --cov=ssh-audit test diff --git a/test/mypy-py2.sh b/test/mypy-py2.sh deleted file mode 100755 index 17ba99e..0000000 --- a/test/mypy-py2.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/sh -_cdir=$(cd -- "$(dirname "$0")" && pwd) -type mypy > /dev/null 2>&1 -if [ $? -ne 0 ]; then - echo "err: mypy (Optional Static Typing for Python) not found." - exit 1 -fi -_htmldir="${_cdir}/../html/mypy-py2" -mkdir -p "${_htmldir}" -env MYPYPATH="${_cdir}/stubs/" mypy \ ---python-version 2.7 \ ---no-warn-incomplete-stub \ ---show-error-context \ ---config-file "${_cdir}/mypy.ini" \ ---html-report "${_htmldir}" "${_cdir}/../ssh-audit.py" diff --git a/test/mypy-py3.sh b/test/mypy-py3.sh deleted file mode 100755 index 81f2421..0000000 --- a/test/mypy-py3.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/sh -_cdir=$(cd -- "$(dirname "$0")" && pwd) -type mypy > /dev/null 2>&1 -if [ $? -ne 0 ]; then - echo "err: mypy (Optional Static Typing for Python) not found." - exit 1 -fi -_htmldir="${_cdir}/../html/mypy-py3" -mkdir -p "${_htmldir}" -env MYPYPATH="${_cdir}/stubs/" mypy \ ---python-version 3.5 \ ---show-error-context \ ---config-file "${_cdir}/mypy.ini" \ ---html-report "${_htmldir}" "${_cdir}/../ssh-audit.py" diff --git a/test/mypy.ini b/test/mypy.ini deleted file mode 100644 index 128ac30..0000000 --- a/test/mypy.ini +++ /dev/null @@ -1,13 +0,0 @@ -[mypy] -ignore_missing_imports = False -follow_imports = error -disallow_untyped_calls = True -disallow_untyped_defs = True -check_untyped_defs = True -disallow_subclassing_any = True -warn_incomplete_stub = True -warn_redundant_casts = True -warn_return_any = True -warn_unused_ignores = True -strict_optional = True -#strict_boolean = False diff --git a/test/prospector.sh b/test/prospector.sh deleted file mode 100755 index 4398ec7..0000000 --- a/test/prospector.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/sh -_cdir=$(cd -- "$(dirname "$0")" && pwd) -type prospector > /dev/null 2>&1 -if [ $? -ne 0 ]; then - echo "err: prospector (Python Static Analysis) not found." - exit 1 -fi -if [ X"$1" == X"" ]; then - _file="${_cdir}/../ssh-audit.py" -else - _file="$1" -fi -prospector -E --profile-path "${_cdir}" -P prospector "${_file}" diff --git a/test/prospector.yml b/test/prospector.yml deleted file mode 100644 index 474af15..0000000 --- a/test/prospector.yml +++ /dev/null @@ -1,42 +0,0 @@ -strictness: veryhigh -doc-warnings: false - -pylint: - disable: - - multiple-imports - - invalid-name - - trailing-whitespace - - options: - max-args: 8 # default: 5 - max-locals: 20 # default: 15 - max-returns: 6 - max-branches: 15 # default: 12 - max-statements: 60 # default: 50 - max-parents: 7 - max-attributes: 8 # default: 7 - min-public-methods: 1 # default: 2 - max-public-methods: 20 - max-bool-expr: 5 - max-nested-blocks: 6 # default: 5 - max-line-length: 80 # default: 100 - ignore-long-lines: ^\s*(#\s+type:\s+.*|[A-Z0-9_]+\s+=\s+.*|('.*':\s+)?\[.*\],?)$ - max-module-lines: 2500 # default: 10000 - -pep8: - disable: - - W191 # indentation contains tabs - - W293 # blank line contains whitespace - - E101 # indentation contains mixed spaces and tabs - - E401 # multiple imports on one line - - E501 # line too long - - E221 # multiple spaces before operator - -pyflakes: - disable: - - F401 # module imported but unused - - F821 # undefined name - -mccabe: - options: - max-complexity: 15 diff --git a/test/requirements-py2.txt b/test/requirements-py2.txt deleted file mode 100644 index bd50c11..0000000 --- a/test/requirements-py2.txt +++ /dev/null @@ -1,5 +0,0 @@ -pytest==3.0.7 -pytest-cov==2.4.0 -lxml==3.7.3 -colorama==0.3.7 -prospector==0.12.4 diff --git a/test/requirements-py3.txt b/test/requirements-py3.txt deleted file mode 100644 index 9957de7..0000000 --- a/test/requirements-py3.txt +++ /dev/null @@ -1,6 +0,0 @@ -pytest==3.0.7 -pytest-cov==2.4.0 -mypy==0.501 -lxml==3.7.3 -colorama==0.3.7 -prospector==0.12.4 From cfae0d020ad4e247ec6a9861e5a38bf7185b0182 Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Sat, 25 Mar 2017 08:33:16 +0200 Subject: [PATCH 17/45] Fix vulture output for Python 3. --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index b5e5acc..92f8ff7 100644 --- a/tox.ini +++ b/tox.ini @@ -77,8 +77,8 @@ commands = python -c "import sys; from subprocess import Popen, PIPE; \ a = ['vulture'] + r'{posargs:{env:SSHAUDIT}}'.split(' '); \ o = Popen(a, shell=False, stdout=PIPE).communicate()[0]; \ - l = [x for x in o.split('\n') if x and 'Unused import' not in x]; \ - print('\n'.join(l)); \ + l = [x for x in o.split(b'\n') if x and b'Unused import' not in x]; \ + print(b'\n'.join(l).decode('utf-8')); \ sys.exit(1 if len(l) > 0 else 0)" [testenv:type] From 29d9e4270d6f42a0a3c1ebcbc9a89fce71bc8a83 Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Sat, 25 Mar 2017 08:44:37 +0200 Subject: [PATCH 18/45] Fix flake8 reported issues. --- ssh-audit.py | 117 +++++++++++++++++++++++++-------------------------- 1 file changed, 58 insertions(+), 59 deletions(-) diff --git a/ssh-audit.py b/ssh-audit.py index b482467..3aaada7 100755 --- a/ssh-audit.py +++ b/ssh-audit.py @@ -1270,10 +1270,10 @@ def values(self): @property def maxlen(self): # type: () -> int - maxlen = 0 def ml(items): # type: (Sequence[text_type]) -> int return max(len(i) for i in items) + maxlen = 0 if self.ssh1kex is not None: maxlen = max(ml(self.ssh1kex.supported_ciphers), ml(self.ssh1kex.supported_authentications), @@ -1445,63 +1445,63 @@ class Security(object): # pylint: disable=too-few-public-methods ['0.4.7', '0.5.2', 1, 'CVE-2012-4560', 7.5, 'cause DoS or execute arbitrary code (buffer overflow)'], ['0.4.7', '0.5.2', 1, 'CVE-2012-4559', 6.8, 'cause DoS or execute arbitrary code (double free)']], 'OpenSSH': [ - ['7.2', '7.2p2', 1, 'CVE-2016-6515', 7.8, 'cause DoS via long password string (crypt CPU consumption)'], - ['1.2.2', '7.2', 1, 'CVE-2016-3115', 5.5, 'bypass command restrictions via crafted X11 forwarding data'], - ['5.4', '7.1', 1, 'CVE-2016-1907', 5.0, 'cause DoS via crafted network traffic (out of bounds read)'], - ['5.4', '7.1p1', 2, 'CVE-2016-0778', 4.6, 'cause DoS via requesting many forwardings (heap based buffer overflow)'], - ['5.0', '7.1p1', 2, 'CVE-2016-0777', 4.0, 'leak data via allowing transfer of entire buffer'], - ['6.0', '7.2p2', 5, 'CVE-2015-8325', 7.2, 'privilege escalation via triggering crafted environment'], - ['6.8', '6.9', 5, 'CVE-2015-6565', 7.2, 'cause DoS via writing to a device (terminal disruption)'], - ['5.0', '6.9', 5, 'CVE-2015-6564', 6.9, 'privilege escalation via leveraging sshd uid'], - ['5.0', '6.9', 5, 'CVE-2015-6563', 1.9, 'conduct impersonation attack'], - ['6.9p1', '6.9p1', 1, 'CVE-2015-5600', 8.5, 'cause Dos or aid in conduct brute force attack (CPU consumption)'], - ['6.0', '6.6', 1, 'CVE-2015-5352', 4.3, 'bypass access restrictions via a specific connection'], - ['6.0', '6.6', 2, 'CVE-2014-2653', 5.8, 'bypass SSHFP DNS RR check via unacceptable host certificate'], - ['5.0', '6.5', 1, 'CVE-2014-2532', 5.8, 'bypass environment restrictions via specific string before wildcard'], - ['1.2', '6.4', 1, 'CVE-2014-1692', 7.5, 'cause DoS via triggering error condition (memory corruption)'], - ['6.2', '6.3', 1, 'CVE-2013-4548', 6.0, 'bypass command restrictions via crafted packet data'], - ['1.2', '5.6', 1, 'CVE-2012-0814', 3.5, 'leak data via debug messages'], - ['1.2', '5.8', 1, 'CVE-2011-5000', 3.5, 'cause DoS via large value in certain length field (memory consumption)'], - ['5.6', '5.7', 2, 'CVE-2011-0539', 5.0, 'leak data or conduct hash collision attack'], - ['1.2', '6.1', 1, 'CVE-2010-5107', 5.0, 'cause DoS via large number of connections (slot exhaustion)'], - ['1.2', '5.8', 1, 'CVE-2010-4755', 4.0, 'cause DoS via crafted glob expression (CPU and memory consumption)'], - ['1.2', '5.6', 1, 'CVE-2010-4478', 7.5, 'bypass authentication check via crafted values'], - ['4.3', '4.8', 1, 'CVE-2009-2904', 6.9, 'privilege escalation via hard links to setuid programs'], - ['4.0', '5.1', 1, 'CVE-2008-5161', 2.6, 'recover plaintext data from ciphertext'], - ['1.2', '4.6', 1, 'CVE-2008-4109', 5.0, 'cause DoS via multiple login attempts (slot exhaustion)'], - ['1.2', '4.8', 1, 'CVE-2008-1657', 6.5, 'bypass command restrictions via modifying session file'], - ['1.2.2', '4.9', 1, 'CVE-2008-1483', 6.9, 'hijack forwarded X11 connections'], - ['4.0', '4.6', 1, 'CVE-2007-4752', 7.5, 'privilege escalation via causing an X client to be trusted'], - ['4.3p2', '4.3p2', 1, 'CVE-2007-3102', 4.3, 'allow attacker to write random data to audit log'], - ['1.2', '4.6', 1, 'CVE-2007-2243', 5.0, 'discover valid usernames through different responses'], - ['4.4', '4.4', 1, 'CVE-2006-5794', 7.5, 'bypass authentication'], - ['4.1', '4.1p1', 1, 'CVE-2006-5229', 2.6, 'discover valid usernames through different time delays'], - ['1.2', '4.3p2', 1, 'CVE-2006-5052', 5.0, 'discover valid usernames through different responses'], - ['1.2', '4.3p2', 1, 'CVE-2006-5051', 9.3, 'cause DoS or execute arbitrary code (double free)'], - ['4.5', '4.5', 1, 'CVE-2006-4925', 5.0, 'cause DoS via invalid protocol sequence (crash)'], - ['1.2', '4.3p2', 1, 'CVE-2006-4924', 7.8, 'cause DoS via crafted packet (CPU consumption)'], - ['3.8.1p1', '3.8.1p1', 1, 'CVE-2006-0883', 5.0, 'cause DoS via connecting multiple times (client connection refusal)'], - ['3.0', '4.2p1', 1, 'CVE-2006-0225', 4.6, 'execute arbitrary code'], - ['2.1', '4.1p1', 1, 'CVE-2005-2798', 5.0, 'leak data about authentication credentials'], - ['3.5', '3.5p1', 1, 'CVE-2004-2760', 6.8, 'leak data through different connection states'], - ['2.3', '3.7.1p2', 1, 'CVE-2004-2069', 5.0, 'cause DoS via large number of connections (slot exhaustion)'], - ['3.0', '3.4p1', 1, 'CVE-2004-0175', 4.3, 'leak data through directoy traversal'], - ['1.2', '3.9p1', 1, 'CVE-2003-1562', 7.6, 'leak data about authentication credentials'], - ['3.1p1', '3.7.1p1', 1, 'CVE-2003-0787', 7.5, 'privilege escalation via modifying stack'], - ['3.1p1', '3.7.1p1', 1, 'CVE-2003-0786', 10.0,'privilege escalation via bypassing authentication'], - ['1.0', '3.7.1', 1, 'CVE-2003-0695', 7.5, 'cause DoS or execute arbitrary code'], - ['1.0', '3.7', 1, 'CVE-2003-0693', 10.0,'execute arbitrary code'], - ['3.0', '3.6.1p2', 1, 'CVE-2003-0386', 7.5, 'bypass address restrictions for connection'], - ['3.1p1', '3.6.1p1', 1, 'CVE-2003-0190', 5.0, 'discover valid usernames through different time delays'], - ['3.2.2', '3.2.2', 1, 'CVE-2002-0765', 7.5, 'bypass authentication'], - ['1.2.2', '3.3p1', 1, 'CVE-2002-0640', 10.0,'execute arbitrary code'], - ['1.2.2', '3.3p1', 1, 'CVE-2002-0639', 10.0,'execute arbitrary code'], - ['2.1', '3.2', 1, 'CVE-2002-0575', 7.5, 'privilege escalation'], - ['2.1', '3.0.2p1', 2, 'CVE-2002-0083', 10.0,'privilege escalation'], - ['3.0', '3.0p1', 1, 'CVE-2001-1507', 7.5, 'bypass authentication'], - ['1.2.3', '3.0.1p1', 5, 'CVE-2001-0872', 7.2, 'privilege escalation via crafted environment variables'], - ['1.2.3', '2.1.1', 1, 'CVE-2001-0361', 4.0, 'recover plaintext from ciphertext'], - ['1.2', '2.1', 1, 'CVE-2000-0525', 10.0,'execute arbitrary code (improper privileges)']] + ['7.2', '7.2p2', 1, 'CVE-2016-6515', 7.8, 'cause DoS via long password string (crypt CPU consumption)'], + ['1.2.2', '7.2', 1, 'CVE-2016-3115', 5.5, 'bypass command restrictions via crafted X11 forwarding data'], + ['5.4', '7.1', 1, 'CVE-2016-1907', 5.0, 'cause DoS via crafted network traffic (out of bounds read)'], + ['5.4', '7.1p1', 2, 'CVE-2016-0778', 4.6, 'cause DoS via requesting many forwardings (heap based buffer overflow)'], + ['5.0', '7.1p1', 2, 'CVE-2016-0777', 4.0, 'leak data via allowing transfer of entire buffer'], + ['6.0', '7.2p2', 5, 'CVE-2015-8325', 7.2, 'privilege escalation via triggering crafted environment'], + ['6.8', '6.9', 5, 'CVE-2015-6565', 7.2, 'cause DoS via writing to a device (terminal disruption)'], + ['5.0', '6.9', 5, 'CVE-2015-6564', 6.9, 'privilege escalation via leveraging sshd uid'], + ['5.0', '6.9', 5, 'CVE-2015-6563', 1.9, 'conduct impersonation attack'], + ['6.9p1', '6.9p1', 1, 'CVE-2015-5600', 8.5, 'cause Dos or aid in conduct brute force attack (CPU consumption)'], + ['6.0', '6.6', 1, 'CVE-2015-5352', 4.3, 'bypass access restrictions via a specific connection'], + ['6.0', '6.6', 2, 'CVE-2014-2653', 5.8, 'bypass SSHFP DNS RR check via unacceptable host certificate'], + ['5.0', '6.5', 1, 'CVE-2014-2532', 5.8, 'bypass environment restrictions via specific string before wildcard'], + ['1.2', '6.4', 1, 'CVE-2014-1692', 7.5, 'cause DoS via triggering error condition (memory corruption)'], + ['6.2', '6.3', 1, 'CVE-2013-4548', 6.0, 'bypass command restrictions via crafted packet data'], + ['1.2', '5.6', 1, 'CVE-2012-0814', 3.5, 'leak data via debug messages'], + ['1.2', '5.8', 1, 'CVE-2011-5000', 3.5, 'cause DoS via large value in certain length field (memory consumption)'], + ['5.6', '5.7', 2, 'CVE-2011-0539', 5.0, 'leak data or conduct hash collision attack'], + ['1.2', '6.1', 1, 'CVE-2010-5107', 5.0, 'cause DoS via large number of connections (slot exhaustion)'], + ['1.2', '5.8', 1, 'CVE-2010-4755', 4.0, 'cause DoS via crafted glob expression (CPU and memory consumption)'], + ['1.2', '5.6', 1, 'CVE-2010-4478', 7.5, 'bypass authentication check via crafted values'], + ['4.3', '4.8', 1, 'CVE-2009-2904', 6.9, 'privilege escalation via hard links to setuid programs'], + ['4.0', '5.1', 1, 'CVE-2008-5161', 2.6, 'recover plaintext data from ciphertext'], + ['1.2', '4.6', 1, 'CVE-2008-4109', 5.0, 'cause DoS via multiple login attempts (slot exhaustion)'], + ['1.2', '4.8', 1, 'CVE-2008-1657', 6.5, 'bypass command restrictions via modifying session file'], + ['1.2.2', '4.9', 1, 'CVE-2008-1483', 6.9, 'hijack forwarded X11 connections'], + ['4.0', '4.6', 1, 'CVE-2007-4752', 7.5, 'privilege escalation via causing an X client to be trusted'], + ['4.3p2', '4.3p2', 1, 'CVE-2007-3102', 4.3, 'allow attacker to write random data to audit log'], + ['1.2', '4.6', 1, 'CVE-2007-2243', 5.0, 'discover valid usernames through different responses'], + ['4.4', '4.4', 1, 'CVE-2006-5794', 7.5, 'bypass authentication'], + ['4.1', '4.1p1', 1, 'CVE-2006-5229', 2.6, 'discover valid usernames through different time delays'], + ['1.2', '4.3p2', 1, 'CVE-2006-5052', 5.0, 'discover valid usernames through different responses'], + ['1.2', '4.3p2', 1, 'CVE-2006-5051', 9.3, 'cause DoS or execute arbitrary code (double free)'], + ['4.5', '4.5', 1, 'CVE-2006-4925', 5.0, 'cause DoS via invalid protocol sequence (crash)'], + ['1.2', '4.3p2', 1, 'CVE-2006-4924', 7.8, 'cause DoS via crafted packet (CPU consumption)'], + ['3.8.1p1', '3.8.1p1', 1, 'CVE-2006-0883', 5.0, 'cause DoS via connecting multiple times (client connection refusal)'], + ['3.0', '4.2p1', 1, 'CVE-2006-0225', 4.6, 'execute arbitrary code'], + ['2.1', '4.1p1', 1, 'CVE-2005-2798', 5.0, 'leak data about authentication credentials'], + ['3.5', '3.5p1', 1, 'CVE-2004-2760', 6.8, 'leak data through different connection states'], + ['2.3', '3.7.1p2', 1, 'CVE-2004-2069', 5.0, 'cause DoS via large number of connections (slot exhaustion)'], + ['3.0', '3.4p1', 1, 'CVE-2004-0175', 4.3, 'leak data through directoy traversal'], + ['1.2', '3.9p1', 1, 'CVE-2003-1562', 7.6, 'leak data about authentication credentials'], + ['3.1p1', '3.7.1p1', 1, 'CVE-2003-0787', 7.5, 'privilege escalation via modifying stack'], + ['3.1p1', '3.7.1p1', 1, 'CVE-2003-0786', 10.0, 'privilege escalation via bypassing authentication'], + ['1.0', '3.7.1', 1, 'CVE-2003-0695', 7.5, 'cause DoS or execute arbitrary code'], + ['1.0', '3.7', 1, 'CVE-2003-0693', 10.0, 'execute arbitrary code'], + ['3.0', '3.6.1p2', 1, 'CVE-2003-0386', 7.5, 'bypass address restrictions for connection'], + ['3.1p1', '3.6.1p1', 1, 'CVE-2003-0190', 5.0, 'discover valid usernames through different time delays'], + ['3.2.2', '3.2.2', 1, 'CVE-2002-0765', 7.5, 'bypass authentication'], + ['1.2.2', '3.3p1', 1, 'CVE-2002-0640', 10.0, 'execute arbitrary code'], + ['1.2.2', '3.3p1', 1, 'CVE-2002-0639', 10.0, 'execute arbitrary code'], + ['2.1', '3.2', 1, 'CVE-2002-0575', 7.5, 'privilege escalation'], + ['2.1', '3.0.2p1', 2, 'CVE-2002-0083', 10.0, 'privilege escalation'], + ['3.0', '3.0p1', 1, 'CVE-2001-1507', 7.5, 'bypass authentication'], + ['1.2.3', '3.0.1p1', 5, 'CVE-2001-0872', 7.2, 'privilege escalation via crafted environment variables'], + ['1.2.3', '2.1.1', 1, 'CVE-2001-0361', 4.0, 'recover plaintext from ciphertext'], + ['1.2', '2.1', 1, 'CVE-2000-0525', 10.0, 'execute arbitrary code (improper privileges)']] } # type: Dict[str, List[List[Any]]] TXT = { 'Dropbear SSH': [ @@ -1785,7 +1785,6 @@ def __init__(self): super(KexGroup14, self).__init__('sha1', 2, p) - def output_algorithms(title, alg_db, alg_type, algorithms, maxlen=0): # type: (str, Dict[str, Dict[str, List[List[Optional[str]]]]], str, List[text_type], int) -> None with OutputBuffer() as obuf: From 6d9f5e6f2ab1ad7a6b1f551e518d0406cd9a1cef Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Sun, 26 Mar 2017 05:39:24 +0300 Subject: [PATCH 19/45] Refactor tox.ini to be more versatile. --- .travis.yml | 11 ++++----- tox.ini | 70 ++++++++++++++++++++++------------------------------- 2 files changed, 34 insertions(+), 47 deletions(-) diff --git a/.travis.yml b/.travis.yml index 0722577..8e07e44 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,5 @@ language: python +sudo: false matrix: include: - python: 2.6 @@ -33,19 +34,17 @@ install: - pip install --upgrade tox coveralls codecov script: - if [ -z "${TOXENV##*py3*}" ]; then - export MYPYBASE=python; if [ -z "${TOXENV##*pypy3*}" ]; then _pydir=$(dirname $(which python)); ln -s -- "${_pydir}/python" "${_pydir}/pypy3"; - export TOXENV=${TOXENV},cov,lint; + export TOXENV=${TOXENV}-test,${TOXENV}-lint; else - export TOXENV=${TOXENV},cov,type,lint; + export TOXENV=${TOXENV}-test,${TOXENV}-type,${TOXENV}-lint; fi else - export MYPYBASE=python-unknown; - export TOXENV=${TOXENV},cov,lint; + export TOXENV=${TOXENV}-test,${TOXENV}-lint; fi - - tox -e $TOXENV + - tox -e $TOXENV,cov after_success: - coveralls - codecov diff --git a/tox.ini b/tox.ini index 92f8ff7..16ba685 100644 --- a/tox.ini +++ b/tox.ini @@ -1,20 +1,36 @@ [tox] -envlist = py26,py27,py33,py34,py35,py36,py37,jython,pypy,pypy3,cov,type,lint +envlist = + py{26,27,py,py3}-{test,pylint,flake8,vulture} + py{33,34,35,36,37}-{test,mypy,pylint,flake8,vulture} + cov skipsdist = true skip_missing_interpreters = true [testenv] deps = - pytest==3.0.7 - coverage==4.3.4 - colorama==0.3.7 + test: pytest==3.0.7 + test,cov: {[testenv:cov]deps} + test,py{33,34,35,36,37}-{type,mypy}: colorama==0.3.7 + py{33,34,35,36,37}-{type,mypy}: {[testenv:mypy]deps} + lint,pylint: {[testenv:pylint]deps} + lint,flake8: {[testenv:flake8]deps} + lint,vulture: {[testenv:vulture]deps} setenv = SSHAUDIT = {toxinidir}/ssh-audit.py - COVERAGE_FILE = {toxinidir}/.coverage.{envname} + test: COVERAGE_FILE = {toxinidir}/.coverage.{envname} + type,mypy: MYPYPATH = {toxinidir}/test/stubs + type,mypy: MYPYHTML = {toxinidir}/html/mypy commands = - coverage run --source ssh-audit -m -- pytest -v {posargs:test} - coverage report --show-missing - coverage html -d {toxinidir}/html/coverage.{envname} + test: coverage run --source ssh-audit -m -- pytest -v {posargs:test} + test: coverage report --show-missing + test: - coverage html -d {toxinidir}/html/coverage.{envname} + py{33,34,35,36,37}-{type,mypy}: {[testenv:mypy]commands} + lint,pylint: {[testenv:pylint]commands} + lint,flake8: {[testenv:flake8]commands} + lint,vulture: {[testenv:vulture]commands} +ignore_outcome = + type: true + lint: true [testenv:cov] deps = @@ -25,32 +41,25 @@ commands = coverage erase coverage combine coverage report --show-missing - coverage html -d {toxinidir}/html/coverage -ignore_outcome = true + - coverage html -d {toxinidir}/html/coverage [testenv:mypy] -basepython = - {env:MYPYBASE:python3.5} deps = colorama==0.3.7 mypy==0.501 lxml==3.7.3 -setenv = - {[testenv]setenv} - MYPYPATH = {toxinidir}/test/stubs - MYPYHTML = {toxinidir}/html/mypy commands = mypy \ --show-error-context \ --config-file {toxinidir}/tox.ini \ - --html-report {env:MYPYHTML}.py3 \ + --html-report {env:MYPYHTML}.py3.{envname} \ {posargs:{env:SSHAUDIT}} mypy \ -2 \ --no-warn-incomplete-stub \ --show-error-context \ --config-file {toxinidir}/tox.ini \ - --html-report {env:MYPYHTML}.py2 \ + --html-report {env:MYPYHTML}.py2.{envname} \ {posargs:{env:SSHAUDIT}} [testenv:pylint] @@ -72,7 +81,8 @@ commands = flake8 {posargs:{env:SSHAUDIT}} [testenv:vulture] -deps = vulture +deps = + vulture commands = python -c "import sys; from subprocess import Popen, PIPE; \ a = ['vulture'] + r'{posargs:{env:SSHAUDIT}}'.split(' '); \ @@ -81,28 +91,6 @@ commands = print(b'\n'.join(l).decode('utf-8')); \ sys.exit(1 if len(l) > 0 else 0)" -[testenv:type] -basepython = - {[testenv:mypy]basepython} -deps = - {[testenv:mypy]deps} -setenv = - {[testenv:mypy]setenv} -commands = - {[testenv:mypy]commands} -ignore_outcome = true - -[testenv:lint] -deps = - {[testenv:pylint]deps} - {[testenv:flake8]deps} - {[testenv:vulture]deps} -commands = - {[testenv:pylint]commands} - {[testenv:flake8]commands} - {[testenv:vulture]commands} -ignore_outcome = true - [mypy] ignore_missing_imports = False From 74d1b5c7b513e8f5a6c1a74c433d695ffa4f55f1 Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Sun, 26 Mar 2017 05:54:14 +0300 Subject: [PATCH 20/45] Fix pylint's bad-builtin and deprecated-lambda with list comprehension. --- ssh-audit.py | 6 +++--- tox.ini | 8 +++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/ssh-audit.py b/ssh-audit.py index 3aaada7..d04c567 100755 --- a/ssh-audit.py +++ b/ssh-audit.py @@ -103,12 +103,12 @@ def __setattr__(self, name, value): if len(self.ipvo) == 0: value = (6,) if ipv == 4 else (4,) else: - value = tuple(filter(lambda x: x != ipv, self.ipvo)) + value = tuple([x for x in self.ipvo if x != ipv]) self.__setattr__('ipvo', value) elif name == 'ipvo': if isinstance(value, (tuple, list)): uniq_value = utils.unique_seq(value) - value = tuple(filter(lambda x: x in (4, 6), uniq_value)) + value = tuple([x for x in uniq_value if x in (4, 6)]) valid = True ipv_both = len(value) == 0 object.__setattr__(self, 'ipv4', ipv_both or 4 in value) @@ -1536,7 +1536,7 @@ def __init__(self, host, port): def _resolve(self, ipvo): # type: (Sequence[int]) -> Iterable[Tuple[int, Tuple[Any, ...]]] - ipvo = tuple(filter(lambda x: x in (4, 6), utils.unique_seq(ipvo))) + ipvo = tuple([x for x in utils.unique_seq(ipvo) if x in (4, 6)]) ipvo_len = len(ipvo) prefer_ipvo = ipvo_len > 0 prefer_ipv4 = prefer_ipvo and ipvo[0] == 4 diff --git a/tox.ini b/tox.ini index 16ba685..9119720 100644 --- a/tox.ini +++ b/tox.ini @@ -110,7 +110,13 @@ strict_optional = True reports = no #output-format = colorized indent-string = \t -disable = locally-disabled, bad-continuation, multiple-imports, invalid-name, trailing-whitespace, missing-docstring +disable = + locally-disabled, + bad-continuation, + multiple-imports, + invalid-name, + trailing-whitespace, + missing-docstring max-complexity = 15 max-args = 8 max-locals = 20 From 3ebb59108b7d05aa6e4bc9c862eececf961942d6 Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Sun, 26 Mar 2017 05:58:39 +0300 Subject: [PATCH 21/45] Ignore pylint's else-if-used in validly used places. --- ssh-audit.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ssh-audit.py b/ssh-audit.py index d04c567..72f3d84 100755 --- a/ssh-audit.py +++ b/ssh-audit.py @@ -99,7 +99,7 @@ def __setattr__(self, name, value): ipv = 4 if name == 'ipv4' else 6 if value: value = tuple(list(self.ipvo) + [ipv]) - else: + else: # pylint: disable=else-if-used if len(self.ipvo) == 0: value = (6,) if ipv == 4 else (4,) else: @@ -1834,7 +1834,7 @@ def output_algorithm(alg_db, alg_type, alg_name, alg_max_len=0): f = out.good f(prefix + alg_name + padding + ' -- ' + text) first = False - else: + else: # pylint: disable=else-if-used if out.verbose: f(prefix + alg_name + padding + ' -- ' + text) else: From 57a8744d03ab498c0c8b148b10c31ddcfc56e3ba Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Sun, 26 Mar 2017 06:24:07 +0300 Subject: [PATCH 22/45] Fix some unused variable warnings. --- ssh-audit.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ssh-audit.py b/ssh-audit.py index 72f3d84..358be10 100755 --- a/ssh-audit.py +++ b/ssh-audit.py @@ -1550,7 +1550,7 @@ def _resolve(self, ipvo): if prefer_ipvo: r = sorted(r, key=lambda x: x[0], reverse=not prefer_ipv4) check = any(stype == rline[2] for rline in r) - for (af, socktype, proto, canonname, addr) in r: + for (af, socktype, _proto, _canonname, addr) in r: if not check or socktype == socket.SOCK_STREAM: yield (af, addr) except socket.error as e: @@ -1877,8 +1877,9 @@ def output_security_sub(sub, software, padlen): if not software.between_versions(vfrom, vtill): continue target, name = line[2:4] # type: int, str - is_server, is_client = target & 1 == 1, target & 2 == 2 - is_local = target & 4 == 4 + is_server = target & 1 == 1 + # is_client = target & 2 == 2 + # is_local = target & 4 == 4 if not is_server: continue p = '' if out.batch else ' ' * (padlen - len(name)) From 76849540be26b6302e3256b186b43a79f5af1109 Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Sun, 26 Mar 2017 06:31:06 +0300 Subject: [PATCH 23/45] It's 2017 already. --- LICENSE | 2 +- ssh-audit.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/LICENSE b/LICENSE index 0eb1032..a778a9a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (C) 2016 Andris Raugulis (moo@arthepsy.eu) +Copyright (C) 2017 Andris Raugulis (moo@arthepsy.eu) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/ssh-audit.py b/ssh-audit.py index 358be10..e1684f7 100755 --- a/ssh-audit.py +++ b/ssh-audit.py @@ -3,7 +3,7 @@ """ The MIT License (MIT) - Copyright (C) 2016 Andris Raugulis (moo@arthepsy.eu) + Copyright (C) 2017 Andris Raugulis (moo@arthepsy.eu) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 0ffb15dd54b9d4663b3109c45bb07ae8b3020779 Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Sun, 26 Mar 2017 06:47:31 +0300 Subject: [PATCH 24/45] Pylint and flake8 is not supported on Python 2.6. --- .travis.yml | 13 +++++++------ tox.ini | 11 ++++++----- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.travis.yml b/.travis.yml index 8e07e44..b5ef195 100644 --- a/.travis.yml +++ b/.travis.yml @@ -33,16 +33,17 @@ matrix: install: - pip install --upgrade tox coveralls codecov script: - - if [ -z "${TOXENV##*py3*}" ]; then + - | + if [ -z "${TOXENV##*py3*}" ]; then if [ -z "${TOXENV##*pypy3*}" ]; then - _pydir=$(dirname $(which python)); - ln -s -- "${_pydir}/python" "${_pydir}/pypy3"; - export TOXENV=${TOXENV}-test,${TOXENV}-lint; + _pydir=$(dirname $(which python)) + ln -s -- "${_pydir}/python" "${_pydir}/pypy3" + export TOXENV=${TOXENV}-test,${TOXENV}-lint else - export TOXENV=${TOXENV}-test,${TOXENV}-type,${TOXENV}-lint; + export TOXENV=${TOXENV}-test,${TOXENV}-type,${TOXENV}-lint fi else - export TOXENV=${TOXENV}-test,${TOXENV}-lint; + export TOXENV=${TOXENV}-test,${TOXENV}-lint fi - tox -e $TOXENV,cov after_success: diff --git a/tox.ini b/tox.ini index 9119720..19c2274 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,7 @@ [tox] envlist = - py{26,27,py,py3}-{test,pylint,flake8,vulture} + py26-{test,vulture} + py{27,py,py3}-{test,pylint,flake8,vulture} py{33,34,35,36,37}-{test,mypy,pylint,flake8,vulture} cov skipsdist = true @@ -12,8 +13,8 @@ deps = test,cov: {[testenv:cov]deps} test,py{33,34,35,36,37}-{type,mypy}: colorama==0.3.7 py{33,34,35,36,37}-{type,mypy}: {[testenv:mypy]deps} - lint,pylint: {[testenv:pylint]deps} - lint,flake8: {[testenv:flake8]deps} + py{27,py,py3,33,34,35,36,37}-{lint,pylint}: {[testenv:pylint]deps} + py{27,py,py3,33,34,35,36,37}-{lint,flake8}: {[testenv:flake8]deps} lint,vulture: {[testenv:vulture]deps} setenv = SSHAUDIT = {toxinidir}/ssh-audit.py @@ -25,8 +26,8 @@ commands = test: coverage report --show-missing test: - coverage html -d {toxinidir}/html/coverage.{envname} py{33,34,35,36,37}-{type,mypy}: {[testenv:mypy]commands} - lint,pylint: {[testenv:pylint]commands} - lint,flake8: {[testenv:flake8]commands} + py{27,py,py3,33,34,35,36,37}-{lint,pylint}: {[testenv:pylint]commands} + py{27,py,py3,33,34,35,36,37}-{lint,flake8}: {[testenv:flake8]commands} lint,vulture: {[testenv:vulture]commands} ignore_outcome = type: true From 95ba7d11ce8db24df614f306d75ac42f46e1b9e8 Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Sun, 26 Mar 2017 07:22:47 +0300 Subject: [PATCH 25/45] Test on Ubuntu 12.04/14.04 and Mac OS X 10.10-10.12. --- .travis.yml | 267 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 248 insertions(+), 19 deletions(-) diff --git a/.travis.yml b/.travis.yml index b5ef195..813934a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,54 +1,283 @@ language: python -sudo: false matrix: include: - - python: 2.6 + # Ubuntu 12.04 + - os: linux + dist: precise + python: 2.6 + env: TOXENV=py26 + - os: linux + dist: precise + python: 2.7 + env: TOXENV=py27 + - os: linux + dist: precise + python: 3.3 + env: TOXENV=py33 + - os: linux + dist: precise + python: 3.4 + env: TOXENV=py34 + - os: linux + dist: precise + python: 3.5 + env: TOXENV=py35 + - os: linux + dist: precise + python: 3.6 + env: TOXENV=py36 + - os: linux + dist: precise + python: 3.7-dev + env: TOXENV=py37 + - os: linux + dist: precise + python: nightly + env: TOXENV=py37 + - os: linux + dist: precise + python: pypy + env: TOXENV=pypy PYORIGIN=pyenv + - os: linux + dist: precise + python: pypy3 + env: TOXENV=pypy3 PYORIGIN=pyenv + # Ubuntu 14.04 + - os: linux + dist: trusty + python: 2.6 env: TOXENV=py26 - - python: 2.7 + - os: linux + dist: trusty + python: 2.7 env: TOXENV=py27 - - python: 3.3 + - os: linux + dist: trusty + python: 3.3 env: TOXENV=py33 - - python: 3.4 + - os: linux + dist: trusty + python: 3.4 env: TOXENV=py34 - - python: 3.5 + - os: linux + dist: trusty + python: 3.5 env: TOXENV=py35 - - python: 3.6 + - os: linux + dist: trusty + python: 3.6 env: TOXENV=py36 - - python: 3.7-dev + - os: linux + dist: trusty + python: 3.7-dev + env: TOXENV=py37 + - os: linux + dist: trusty + python: nightly env: TOXENV=py37 - - python: nightly + - os: linux + dist: trusty + python: 2.7 # NOTE: workaround for trusty + env: TOXENV=pypy PYORIGIN=pyenv + - os: linux + dist: trusty + python: 3.5 # NOTE: workaround for trusty + env: TOXENV=pypy3 PYORIGIN=pyenv + # Mac OS X 10.10 Yosemite + - os: osx + osx_image: xcode6.4 + language: generic + env: TOXENV=py26 + - os: osx + osx_image: xcode6.4 + language: generic + env: TOXENV=py27 + - os: osx + osx_image: xcode6.4 + language: generic + env: TOXENV=py33 + - os: osx + osx_image: xcode6.4 + language: generic + env: TOXENV=py34 + - os: osx + osx_image: xcode6.4 + language: generic + env: TOXENV=py35 + - os: osx + osx_image: xcode6.4 + language: generic + env: TOXENV=py36 + - os: osx + osx_image: xcode6.4 + language: generic env: TOXENV=py37 - - python: pypy + - os: osx + osx_image: xcode6.4 + language: generic env: TOXENV=pypy - - python: pypy3 + - os: osx + osx_image: xcode6.4 + language: generic env: TOXENV=pypy3 - - python: pypy3.3-5.2-alpha1 + # Mac OS X 10.11 El Capitan + - os: osx + osx_image: xcode7.3 + language: generic + env: TOXENV=py26 + - os: osx + osx_image: xcode7.3 + language: generic + env: TOXENV=py27 + - os: osx + osx_image: xcode7.3 + language: generic + env: TOXENV=py33 + - os: osx + osx_image: xcode7.3 + language: generic + env: TOXENV=py34 + - os: osx + osx_image: xcode7.3 + language: generic + env: TOXENV=py35 + - os: osx + osx_image: xcode7.3 + language: generic + env: TOXENV=py36 + - os: osx + osx_image: xcode7.3 + language: generic + env: TOXENV=py37 + - os: osx + osx_image: xcode7.3 + language: generic + env: TOXENV=pypy + - os: osx + osx_image: xcode7.3 + language: generic env: TOXENV=pypy3 - - python: pypy3.3-5.5-alpha + # macOS 10.12 Sierra + - os: osx + osx_image: xcode8.2 + language: generic + env: TOXENV=py26 + - os: osx + osx_image: xcode8.2 + language: generic + env: TOXENV=py27 + - os: osx + osx_image: xcode8.2 + language: generic + env: TOXENV=py33 + - os: osx + osx_image: xcode8.2 + language: generic + env: TOXENV=py34 + - os: osx + osx_image: xcode8.2 + language: generic + env: TOXENV=py35 + - os: osx + osx_image: xcode8.2 + language: generic + env: TOXENV=py36 + - os: osx + osx_image: xcode8.2 + language: generic + env: TOXENV=py37 + - os: osx + osx_image: xcode8.2 + language: generic + env: TOXENV=pypy + - os: osx + osx_image: xcode8.2 + language: generic env: TOXENV=pypy3 allow_failures: - - python: pypy3 - - python: 3.7-dev - - python: nightly + - env: TOXENV=3.7-dev + - env: TOXENV=nightly +sudo: false + +before_install: + - | + if [ X"$(uname -s)" == X"Darwin" ]; then + sw_vers + brew update || brew update + brew install autoconf pkg-config openssl readline xz + brew upgrade autoconf pkg-config openssl readline xz + PYORIGIN=pyenv + fi + - | + case "${PYORIGIN}" in + pyenv) + rm -rf ~/.pyenv + git clone --depth 1 https://github.com/yyuu/pyenv.git ~/.pyenv + PATH="$HOME/.pyenv/bin:$PATH" + eval "$(pyenv init -)" + pyenv -v + if [ X"$(uname -s)" == X"Darwin" ]; then + [ -z "${PYPY2VERSION}" ] && PYPY2VERSION=pypy2-5.7.0 + # NOTE: latest binary for osx is pypy3.3-5.5-alpha + [ -z "${PYPY3VERSION}" ] && PYPY3VERSION=pypy3.3-5.5-alpha + else + [ -z "${PYPY2VERSION}" ] && PYPY2VERSION=pypy-portable-5.7.0 + [ -z "${PYPY3VERSION}" ] && PYPY3VERSION=pypy3-portable-5.7.0 + fi + case "${TOXENV}" in + py26) pyenv install 2.6.9 || exit 1; pyenv global 2.6.9 ;; + py33) pyenv install 3.3.6 || exit 1; pyenv global 3.3.6 ;; + py34) pyenv install 3.4.6 || exit 1; pyenv global 3.4.6 ;; + py35) pyenv install 3.5.3 || exit 1; pyenv global 3.5.3 ;; + py36) pyenv install 3.6.1 || exit 1; pyenv global 3.6.1 ;; + py37) pyenv install 3.7-dev || exit 1; pyenv global 3.7-dev ;; + pypy) pyenv install ${PYPY2VERSION} || exit 1; pyenv global ${PYPY2VERSION} ;; + pypy3) pyenv install ${PYPY3VERSION} || exit 1; pyenv global ${PYPY3VERSION} ;; + esac + pyenv rehash + ;; + esac + - | + PIPOPT=$(python -c 'import sys; print("" if hasattr(sys, "real_prefix") else "--user")') + if [ -z "${TOXENV##py2*}" ]; then + curl -O https://bootstrap.pypa.io/get-pip.py + python get-pip.py ${PIPOPT} + fi + if [ X"${TOXENV}" == X"py26" ]; then + python -c 'import pip; pip.main();' install ${PIPOPT} -U pip virtualenv + python -c 'import virtualenv; virtualenv.main();' ~/.venv + else + python -m pip install ${PIPOPT} -U pip virtualenv + python -m virtualenv ~/.venv + fi + source ~/.venv/bin/activate + install: - pip install --upgrade tox coveralls codecov + script: - | if [ -z "${TOXENV##*py3*}" ]; then if [ -z "${TOXENV##*pypy3*}" ]; then + # NOTE: workaround for travis environment _pydir=$(dirname $(which python)) ln -s -- "${_pydir}/python" "${_pydir}/pypy3" - export TOXENV=${TOXENV}-test,${TOXENV}-lint + # NOTE: do not lint, as it hangs when flake8 is run + # NOTE: do not type, as it can't install dependencies + TOXENV=${TOXENV}-test else - export TOXENV=${TOXENV}-test,${TOXENV}-type,${TOXENV}-lint + TOXENV=${TOXENV}-test,${TOXENV}-type,${TOXENV}-lint fi else - export TOXENV=${TOXENV}-test,${TOXENV}-lint + # NOTE: do not type, as it isn't supported on py2x + TOXENV=${TOXENV}-test,${TOXENV}-lint fi - tox -e $TOXENV,cov + after_success: - coveralls - codecov + after_failure: - cat .tox/log/* - cat .tox/*/log/* From e91bbb5e307c304d4e7b6f5f35a7c1e71615fe9d Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Tue, 28 Mar 2017 05:53:47 +0300 Subject: [PATCH 26/45] Better testing environment. --- .travis.yml | 270 +++++------------------------------------ test/tools/ci-linux.sh | 225 ++++++++++++++++++++++++++++++++++ 2 files changed, 254 insertions(+), 241 deletions(-) create mode 100755 test/tools/ci-linux.sh diff --git a/.travis.yml b/.travis.yml index 813934a..30191d0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,283 +1,71 @@ language: python +sudo: false matrix: include: - # Ubuntu 12.04 + # (default) - os: linux - dist: precise python: 2.6 - env: TOXENV=py26 - os: linux - dist: precise python: 2.7 - env: TOXENV=py27 - os: linux - dist: precise python: 3.3 - env: TOXENV=py33 - os: linux - dist: precise python: 3.4 - env: TOXENV=py34 - os: linux - dist: precise python: 3.5 - env: TOXENV=py35 - os: linux - dist: precise python: 3.6 - env: TOXENV=py36 - os: linux - dist: precise python: 3.7-dev - env: TOXENV=py37 - - os: linux - dist: precise - python: nightly - env: TOXENV=py37 - - os: linux - dist: precise - python: pypy - env: TOXENV=pypy PYORIGIN=pyenv + # Ubuntu 12.04 - os: linux dist: precise - python: pypy3 - env: TOXENV=pypy3 PYORIGIN=pyenv + language: generic + env: PY_VER=py26,py27,py33,py34,py35,py36 PY_ORIGIN=pyenv # Ubuntu 14.04 - os: linux dist: trusty - python: 2.6 - env: TOXENV=py26 - - os: linux - dist: trusty - python: 2.7 - env: TOXENV=py27 - - os: linux - dist: trusty - python: 3.3 - env: TOXENV=py33 - - os: linux - dist: trusty - python: 3.4 - env: TOXENV=py34 - - os: linux - dist: trusty - python: 3.5 - env: TOXENV=py35 - - os: linux - dist: trusty - python: 3.6 - env: TOXENV=py36 - - os: linux - dist: trusty - python: 3.7-dev - env: TOXENV=py37 - - os: linux - dist: trusty - python: nightly - env: TOXENV=py37 - - os: linux - dist: trusty - python: 2.7 # NOTE: workaround for trusty - env: TOXENV=pypy PYORIGIN=pyenv - - os: linux - dist: trusty - python: 3.5 # NOTE: workaround for trusty - env: TOXENV=pypy3 PYORIGIN=pyenv - # Mac OS X 10.10 Yosemite - - os: osx - osx_image: xcode6.4 - language: generic - env: TOXENV=py26 - - os: osx - osx_image: xcode6.4 - language: generic - env: TOXENV=py27 - - os: osx - osx_image: xcode6.4 - language: generic - env: TOXENV=py33 - - os: osx - osx_image: xcode6.4 - language: generic - env: TOXENV=py34 - - os: osx - osx_image: xcode6.4 - language: generic - env: TOXENV=py35 - - os: osx - osx_image: xcode6.4 - language: generic - env: TOXENV=py36 - - os: osx - osx_image: xcode6.4 language: generic - env: TOXENV=py37 - - os: osx - osx_image: xcode6.4 - language: generic - env: TOXENV=pypy - - os: osx - osx_image: xcode6.4 - language: generic - env: TOXENV=pypy3 - # Mac OS X 10.11 El Capitan - - os: osx - osx_image: xcode7.3 - language: generic - env: TOXENV=py26 - - os: osx - osx_image: xcode7.3 - language: generic - env: TOXENV=py27 - - os: osx - osx_image: xcode7.3 - language: generic - env: TOXENV=py33 - - os: osx - osx_image: xcode7.3 - language: generic - env: TOXENV=py34 - - os: osx - osx_image: xcode7.3 - language: generic - env: TOXENV=py35 - - os: osx - osx_image: xcode7.3 - language: generic - env: TOXENV=py36 - - os: osx - osx_image: xcode7.3 - language: generic - env: TOXENV=py37 - - os: osx - osx_image: xcode7.3 - language: generic - env: TOXENV=pypy - - os: osx - osx_image: xcode7.3 - language: generic - env: TOXENV=pypy3 + env: PY_VER=py26,py27,py33,py34,py35,py36 PY_ORIGIN=pyenv # macOS 10.12 Sierra - os: osx osx_image: xcode8.2 language: generic - env: TOXENV=py26 - - os: osx - osx_image: xcode8.2 - language: generic - env: TOXENV=py27 - - os: osx - osx_image: xcode8.2 - language: generic - env: TOXENV=py33 - - os: osx - osx_image: xcode8.2 - language: generic - env: TOXENV=py34 - - os: osx - osx_image: xcode8.2 - language: generic - env: TOXENV=py35 - - os: osx - osx_image: xcode8.2 - language: generic - env: TOXENV=py36 - - os: osx - osx_image: xcode8.2 - language: generic - env: TOXENV=py37 + env: PY_VER=py26,py27,py33,py34,py35,py36 + # Mac OS X 10.11 El Capitan - os: osx - osx_image: xcode8.2 + osx_image: xcode7.3 language: generic - env: TOXENV=pypy + env: PY_VER=py26,py27,py33,py34,py35,py36 + # Mac OS X 10.10 Yosemite - os: osx - osx_image: xcode8.2 + osx_image: xcode6.4 language: generic - env: TOXENV=pypy3 + env: PY_VER=py26,py27,py33,py34,py35,py36 allow_failures: - - env: TOXENV=3.7-dev - - env: TOXENV=nightly -sudo: false + - python: 3.7-dev + - env: PY_VER=py37 + - env: PY_VER=py37/pyenv + - env: PY_VER=py37 PY_ORIGIN=pyenv + fast_finish: true + +cache: + - pip + - directories: + - $HOME/.pyenv.cache before_install: - - | - if [ X"$(uname -s)" == X"Darwin" ]; then - sw_vers - brew update || brew update - brew install autoconf pkg-config openssl readline xz - brew upgrade autoconf pkg-config openssl readline xz - PYORIGIN=pyenv - fi - - | - case "${PYORIGIN}" in - pyenv) - rm -rf ~/.pyenv - git clone --depth 1 https://github.com/yyuu/pyenv.git ~/.pyenv - PATH="$HOME/.pyenv/bin:$PATH" - eval "$(pyenv init -)" - pyenv -v - if [ X"$(uname -s)" == X"Darwin" ]; then - [ -z "${PYPY2VERSION}" ] && PYPY2VERSION=pypy2-5.7.0 - # NOTE: latest binary for osx is pypy3.3-5.5-alpha - [ -z "${PYPY3VERSION}" ] && PYPY3VERSION=pypy3.3-5.5-alpha - else - [ -z "${PYPY2VERSION}" ] && PYPY2VERSION=pypy-portable-5.7.0 - [ -z "${PYPY3VERSION}" ] && PYPY3VERSION=pypy3-portable-5.7.0 - fi - case "${TOXENV}" in - py26) pyenv install 2.6.9 || exit 1; pyenv global 2.6.9 ;; - py33) pyenv install 3.3.6 || exit 1; pyenv global 3.3.6 ;; - py34) pyenv install 3.4.6 || exit 1; pyenv global 3.4.6 ;; - py35) pyenv install 3.5.3 || exit 1; pyenv global 3.5.3 ;; - py36) pyenv install 3.6.1 || exit 1; pyenv global 3.6.1 ;; - py37) pyenv install 3.7-dev || exit 1; pyenv global 3.7-dev ;; - pypy) pyenv install ${PYPY2VERSION} || exit 1; pyenv global ${PYPY2VERSION} ;; - pypy3) pyenv install ${PYPY3VERSION} || exit 1; pyenv global ${PYPY3VERSION} ;; - esac - pyenv rehash - ;; - esac - - | - PIPOPT=$(python -c 'import sys; print("" if hasattr(sys, "real_prefix") else "--user")') - if [ -z "${TOXENV##py2*}" ]; then - curl -O https://bootstrap.pypa.io/get-pip.py - python get-pip.py ${PIPOPT} - fi - if [ X"${TOXENV}" == X"py26" ]; then - python -c 'import pip; pip.main();' install ${PIPOPT} -U pip virtualenv - python -c 'import virtualenv; virtualenv.main();' ~/.venv - else - python -m pip install ${PIPOPT} -U pip virtualenv - python -m virtualenv ~/.venv - fi - source ~/.venv/bin/activate + - source test/tools/ci-linux.sh + - ci_step_before_install install: - - pip install --upgrade tox coveralls codecov + - ci_step_install script: - - | - if [ -z "${TOXENV##*py3*}" ]; then - if [ -z "${TOXENV##*pypy3*}" ]; then - # NOTE: workaround for travis environment - _pydir=$(dirname $(which python)) - ln -s -- "${_pydir}/python" "${_pydir}/pypy3" - # NOTE: do not lint, as it hangs when flake8 is run - # NOTE: do not type, as it can't install dependencies - TOXENV=${TOXENV}-test - else - TOXENV=${TOXENV}-test,${TOXENV}-type,${TOXENV}-lint - fi - else - # NOTE: do not type, as it isn't supported on py2x - TOXENV=${TOXENV}-test,${TOXENV}-lint - fi - - tox -e $TOXENV,cov + - ci_step_script after_success: - - coveralls - - codecov + - ci_step_success after_failure: - - cat .tox/log/* - - cat .tox/*/log/* + - ci_step_failure diff --git a/test/tools/ci-linux.sh b/test/tools/ci-linux.sh new file mode 100755 index 0000000..c95f389 --- /dev/null +++ b/test/tools/ci-linux.sh @@ -0,0 +1,225 @@ +#!/bin/sh + +CI_VERBOSE=1 + +ci_err() { [ $1 -ne 0 ] && echo "err: $2" >&2 && exit 1; } +ci_is_osx() { [ X"$(uname -s)" == X"Darwin" ]; } + +ci_get_py_ver() { + local _v + case "$1" in + py26) _v=2.6.9 ;; + py27) _v=2.7.13 ;; + py33) _v=3.3.6 ;; + py34) _v=3.4.6 ;; + py35) _v=3.5.3 ;; + py36) _v=3.6.1 ;; + py37) _v=3.7-dev ;; + pypy) ci_is_osx && _v=pypy2-5.7.0 || _v=pypy-portable-5.7.0 ;; + pypy3) ci_is_osx && _v=pypy3.3-5.5-alpha || _v=pypy3-portable-5.7.0 ;; + *) [ -z "$1" ] && _v=$(python -V 2>&1 | cut -d ' ' -f 2) || _v="$1" ;; + esac + echo "${_v}" +} + +ci_get_py_env() { + if [ -z "$1" ]; then + set -- "$(python -V 2>&1)" + fi + case "$1" in + pypy|pypy2|pypy-*|pypy2-*) echo "pypy" ;; + pypy3|pypy3*) echo "pypy3" ;; + *) + local _v=$(echo "$1" | head -1 | sed -e 's/[^0-9]//g' | cut -c1-2;) + echo "$1" | tail -1 | grep -qi pypy + if [ $? -eq 0 ]; then + case "${_ver}" in + 2*) echo "pypy" ;; + *) echo "pypy3" ;; + esac + else + echo "py${_v}" + fi + esac +} + +ci_pyenv_setup() { + rm -rf ~/.pyenv + git clone --depth 1 https://github.com/yyuu/pyenv.git ~/.pyenv + PYENV_ROOT=$HOME/.pyenv + PATH="$HOME/.pyenv/bin:$PATH" + eval "$(pyenv init -)" + [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] $(pyenv -v)" +} + +ci_pyenv_install() { + CI_PYENV_CACHE=~/.pyenv.cache + type pyenv > /dev/null 2>&1 + ci_err $? "pyenv not found" + local _py_ver=$(ci_get_py_ver "$1") + local _py_env=$(ci_get_py_env "${_py_ver}") + local _nocache + case "${_py_env}" in + py37) _nocache=1 ;; + esac + [ -z "${PYENV_ROOT}" ] && PYENV_ROOT="$HOME/.pyenv" + local _py_ver_dir="${PYENV_ROOT}/versions/${_py_ver}" + local _py_ver_cached_dir="${CI_PYENV_CACHE}/${_py_ver}" + if [ -z "${_nocache}" ]; then + if [ ! -d "${_py_ver_dir}" ]; then + if [ -d "${_py_ver_cached_dir}" ]; then + [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] using cached pyenv ${_py_ver}" + ln -s "${_py_ver_cached_dir}" "${_py_ver_dir}" + fi + fi + fi + if [ ! -d "${_py_ver_dir}" ]; then + [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] pyenv install ${_py_ver}" + pyenv install -s "${_py_ver}" + ci_err $? "pyenv failed to install ${_py_ver}" + if [ -z "${_nocache}" ]; then + [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] pyenv cache ${_py_ver}" + rm -rf -- "${_py_ver_cached_dir}" + mkdir -p -- "${CI_PYENV_CACHE}" + mv "${_py_ver_dir}" "${_py_ver_cached_dir}" + ln -s "${_py_ver_cached_dir}" "${_py_ver_dir}" + fi + fi + pyenv rehash +} + +ci_pyenv_use() { + type pyenv > /dev/null 2>&1 + ci_err $? "pyenv not found" + local _py_ver=$(ci_get_py_ver "$1") + pyenv shell "${_py_ver}" + ci_err $? "pyenv could not use ${_py_ver}" + [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] using python version: $(python -V 2>&1)" +} + +ci_pip_setup() { + local _py_ver=$(ci_get_py_ver "$1") + local _py_env=$(ci_get_py_env "${_py_ver}") + PIPOPT=$(python -c 'import sys; print("" if hasattr(sys, "real_prefix") else "--user")') + if [ -z "${_py_env##py2*}" ]; then + curl -O https://bootstrap.pypa.io/get-pip.py + python get-pip.py ${PIPOPT} + ci_err $? "failed to install pip" + fi + if [ X"${_py_env}" == X"py26" ]; then + python -c 'import pip; pip.main();' install ${PIPOPT} -U pip virtualenv + else + python -m pip install ${PIPOPT} -U pip virtualenv + fi +} + +ci_venv_setup() { + local _py_ver=$(ci_get_py_ver "$1") + local _py_env=$(ci_get_py_env "${_py_ver}") + local VENV_DIR=~/.venv/${_py_ver} + mkdir -p -- ~/.venv + rm -rf -- "${VENV_DIR}" + if [ X"${_py_env}" == X"py26" ]; then + python -c 'import virtualenv; virtualenv.main();' "${VENV_DIR}" + else + python -m virtualenv "${VENV_DIR}" + fi +} + +ci_venv_use() { + local _py_ver=$(ci_get_py_ver "$1") + local _py_env=$(ci_get_py_env "${_py_ver}") + local VENV_DIR=~/.venv/${_py_ver} + . "${VENV_DIR}/bin/activate" + [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] virtualenv python path: $(which python)" +} + +ci_run_wrapped() { + local _versions=$(echo "${PY_VER}" | sed -e 's/,/ /g') + [ -z "${_versions}" ] && eval "$1" + for _i in ${_versions}; do + local _v=$(echo "$_i" | cut -d '/' -f 1) + local _o=$(echo "$_i" | cut -d '/' -sf 2) + [ -z "${_o}" ] && _o="${PY_ORIGIN}" + eval "$1" "${_v}" "${_o}" + done +} + +ci_step_before_install_wrapped() { + local _py_ver="$1" + local _py_ori="$2" + case "${_py_ori}" in + pyenv) + if [ "${CI_PYENV_SETUP}" -eq 0 ]; then + ci_pyenv_setup + CI_PYENV_SETUP=1 + fi + ci_pyenv_install "${_py_ver}" + ci_pyenv_use "${_py_ver}" + ;; + esac + ci_pip_setup "${_py_ver}" + ci_venv_setup "${_py_ver}" +} + +ci_step_before_install() { + if ci_is_osx; then + [ ${CI_VERBOSE} -gt 0 ] && sw_vers + brew update || brew update + brew install autoconf pkg-config openssl readline xz + brew upgrade autoconf pkg-config openssl readline xz + PY_ORIGIN=pyenv + fi + CI_PYENV_SETUP=0 + ci_run_wrapped "ci_step_before_install_wrapped" + if [ "${CI_PYENV_SETUP}" -eq 1 ]; then + pyenv shell --unset + [ ${CI_VERBOSE} -gt 0 ] && pyenv versions + fi +} + +ci_step_install_wrapped() { + local _py_ver="$1" + ci_venv_use "${_py_ver}" + pip install -U tox coveralls codecov +} + +ci_step_script_wrapped() { + local _py_ver="$1" + local _py_ori="$2" + local _py_env=$(ci_get_py_env "${_py_ver}") + ci_venv_use "${_py_ver}" + if [ -z "${_py_env##*py3*}" ]; then + if [ -z "${_py_env##*pypy3*}" ]; then + # NOTE: workaround for travis environment + _pydir=$(dirname $(which python)) + ln -s -- "${_pydir}/python" "${_pydir}/pypy3" + # NOTE: do not lint, as it hangs when flake8 is run + # NOTE: do not type, as it can't install dependencies + TOXENV=${_py_env}-test + else + TOXENV=${_py_env}-test,${_py_env}-type,${_py_env}-lint + fi + else + # NOTE: do not type, as it isn't supported on py2x + TOXENV=${_py_env}-test,${_py_env}-lint + fi + tox -e $TOXENV,cov +} + +ci_step_success_wrapped() { + local _py_ver="$1" + local _py_ori="$2" + ci_venv_use "${_py_ver}" + coveralls + codecov +} + +ci_step_failure() { + cat .tox/log/* + cat .tox/*/log/* +} + +ci_step_install() { ci_run_wrapped "ci_step_install_wrapped"; } +ci_step_script() { ci_run_wrapped "ci_step_script_wrapped"; } +ci_step_success() { ci_run_wrapped "ci_step_success_wrapped"; } From 2f7c64d896fc21d5c87860666939d223ddf20886 Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Tue, 28 Mar 2017 10:25:55 +0300 Subject: [PATCH 27/45] Report python version in CI. --- test/tools/ci-linux.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/tools/ci-linux.sh b/test/tools/ci-linux.sh index c95f389..543b213 100755 --- a/test/tools/ci-linux.sh +++ b/test/tools/ci-linux.sh @@ -94,7 +94,7 @@ ci_pyenv_use() { local _py_ver=$(ci_get_py_ver "$1") pyenv shell "${_py_ver}" ci_err $? "pyenv could not use ${_py_ver}" - [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] using python version: $(python -V 2>&1)" + [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] using python: $(python -V 2>&1)" } ci_pip_setup() { @@ -131,7 +131,7 @@ ci_venv_use() { local _py_env=$(ci_get_py_env "${_py_ver}") local VENV_DIR=~/.venv/${_py_ver} . "${VENV_DIR}/bin/activate" - [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] virtualenv python path: $(which python)" + [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] using python: $(python -V 2>&1)" } ci_run_wrapped() { From 041805f6088479540a27bc0a841f0bc86ffdb032 Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Tue, 28 Mar 2017 17:08:36 +0300 Subject: [PATCH 28/45] Test with AppVeyor environment. --- .appveyor.yml | 37 ++++++++++++ README.md | 3 +- test/tools/ci-win.cmd | 131 ++++++++++++++++++++++++++++++++++++++++++ tox.ini | 2 +- 4 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 .appveyor.yml create mode 100644 test/tools/ci-win.cmd diff --git a/.appveyor.yml b/.appveyor.yml new file mode 100644 index 0000000..a367a30 --- /dev/null +++ b/.appveyor.yml @@ -0,0 +1,37 @@ +version: '1.7.1.dev.{build}' + +build: off +branches: + only: + - master + - develop + +environment: + matrix: + - PYTHON: "C:\\Python26" + - PYTHON: "C:\\Python26-x64" + - PYTHON: "C:\\Python27" + - PYTHON: "C:\\Python27-x64" + - PYTHON: "C:\\Python33" + - PYTHON: "C:\\Python33-x64" + - PYTHON: "C:\\Python34" + - PYTHON: "C:\\Python34-x64" + - PYTHON: "C:\\Python35" + - PYTHON: "C:\\Python35-x64" + - PYTHON: "C:\\Python36" + - PYTHON: "C:\\Python36-x64" +matrix: + fast_finish: true + +cache: + - '%LOCALAPPDATA%\pip\Cache' + - .downloads -> .appveyor.yml + +install: + - "cmd /c .\\test\\tools\\ci-win.cmd install" + +test_script: + - "cmd /c .\\test\\tools\\ci-win.cmd test" + +on_failure: + - ps: get-content .tox\*\log\* diff --git a/README.md b/README.md index c78d779..d709a1a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # ssh-audit -[![build status](https://api.travis-ci.org/arthepsy/ssh-audit.svg)](https://travis-ci.org/arthepsy/ssh-audit) +[![travis build status](https://api.travis-ci.org/arthepsy/ssh-audit.svg?branch=develop)](https://travis-ci.org/arthepsy/ssh-audit) +[![appveyor build status](https://ci.appveyor.com/api/projects/status/4m5r73m0r023edil/branch/develop?svg=true)](https://ci.appveyor.com/project/arthepsy/ssh-audit) [![codecov](https://codecov.io/gh/arthepsy/ssh-audit/branch/develop/graph/badge.svg)](https://codecov.io/gh/arthepsy/ssh-audit) [![coverage status](https://coveralls.io/repos/github/arthepsy/ssh-audit/badge.svg)](https://coveralls.io/github/arthepsy/ssh-audit) **ssh-audit** is a tool for ssh server auditing. diff --git a/test/tools/ci-win.cmd b/test/tools/ci-win.cmd new file mode 100644 index 0000000..103036c --- /dev/null +++ b/test/tools/ci-win.cmd @@ -0,0 +1,131 @@ +@ECHO OFF + +IF "%PYTHON%" == "" ( + ECHO PYTHON environment variable not set + EXIT 1 +) +SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%" +FOR /F %%i IN ('python -c "import platform; print(platform.python_version());"') DO ( + SET PYTHON_VERSION=%%i +) +SET PYTHON_VERSION_MAJOR=%PYTHON_VERSION:~0,1% +IF "%PYTHON_VERSION:~3,1%" == "." ( + SET PYTHON_VERSION_MINOR=%PYTHON_VERSION:~2,1% +) ELSE ( + SET PYTHON_VERSION_MINOR=%PYTHON_VERSION:~2,2% +) +FOR /F %%i IN ('python -c "import struct; print(struct.calcsize(\"P\")*8)"') DO ( + SET PYTHON_ARCH=%%i +) +CALL :devenv + +IF /I "%1"=="" ( + SET target=test +) ELSE ( + SET target=%1 +) + +echo [CI] TARGET=%target% +GOTO %target% + +:devenv +SET WIN_SDK_ROOT=C:\Program Files\Microsoft SDKs\Windows +SET VS2015_ROOT=C:\Program Files (x86)\Microsoft Visual Studio 14.0 +IF %PYTHON_VERSION_MAJOR% == 2 ( + SET WINDOWS_SDK_VERSION="v7.0" +) ELSE IF %PYTHON_VERSION_MAJOR% == 3 ( + IF %PYTHON_VERSION_MAJOR% LEQ 4 ( + SET WINDOWS_SDK_VERSION="v7.1" + ) ELSE ( + SET WINDOWS_SDK_VERSION="2015" + ) +) ELSE ( + ECHO Unsupported Python version: "%PYTHON_VERSION%" + EXIT 1 +) +SETLOCAL ENABLEDELAYEDEXPANSION +IF %PYTHON_ARCH% == 32 (SET PYTHON_ARCHX=x86) ELSE (SET PYTHON_ARCHX=x64) +IF %WINDOWS_SDK_VERSION% == "2015" ( + "%VS2015_ROOT%\VC\vcvarsall.bat" %PYTHON_ARCHX% +) ELSE ( + SET DISTUTILS_USE_SDK=1 + SET MSSdk=1 + "%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Setup\WindowsSdkVer.exe" -q -version:%WINDOWS_SDK_VERSION% + "%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Bin\SetEnv.cmd" /%PYTHON_ARCHX% /release +) +GOTO :eof + +:install +pip install --user --upgrade pip virtualenv +SET VENV_DIR=.venv\%PYTHON_VERSION% +rmdir /s /q %VENV_DIR% > nul 2>nul +mkdir .venv > nul 2>nul +IF "%PYTHON_VERSION_MAJOR%%PYTHON_VERSION_MINOR%" == "26" ( + python -c "import virtualenv; virtualenv.main();" %VENV_DIR% +) ELSE ( + python -m virtualenv %VENV_DIR% +) +CALL %VENV_DIR%\Scripts\activate +python -V +pip install tox +deactivate +GOTO :eof + +:install_deps +SET LXML_FILE= +SET LXML_URL= +IF %PYTHON_VERSION_MAJOR% == 3 ( + IF %PYTHON_VERSION_MINOR% == 3 ( + IF %PYTHON_ARCH% == 32 ( + SET LXML_FILE=lxml-3.7.3.win32-py3.3.exe + SET LXML_URL=https://pypi.python.org/packages/66/fd/b82a54e7a15e91184efeef4b659379d0581a73cf78239d70feb0f0877841/lxml-3.7.3.win32-py3.3.exe + ) ELSE ( + SET LXML_FILE=lxml-3.7.3.win-amd64-py3.3.exe + SET LXML_URL=https://pypi.python.org/packages/dc/bc/4742b84793fa1fd991b5d2c6f2e5d32695659d6cfedf5c66aef9274a8723/lxml-3.7.3.win-amd64-py3.3.exe + ) + ) ELSE IF %PYTHON_VERSION_MINOR% == 4 ( + IF %PYTHON_ARCH% == 32 ( + SET LXML_FILE=lxml-3.7.3.win32-py3.4.exe + SET LXML_URL=https://pypi.python.org/packages/88/33/265459d68d465ddc707621e6471989f5c2cb0d43f230f516800ffd629af7/lxml-3.7.3.win32-py3.4.exe + ) ELSE ( + SET LXML_FILE=lxml-3.7.3.win-amd64-py3.4.exe + SET LXML_URL=https://pypi.python.org/packages/2d/65/e47db7f36a69a1b59b4f661e42d699d6c43e663b8fd91035e6f7681d017e/lxml-3.7.3.win-amd64-py3.4.exe + ) + ) +) +IF NOT "%LXML_FILE%" == "" ( + CALL :download %LXML_URL% .downloads\%LXML_FILE% + easy_install --user .downloads\%LXML_FILE% +) +GOTO :eof + +:test + SET VENV_DIR=.venv\%PYTHON_VERSION% + CALL %VENV_DIR%\Scripts\activate + IF "%TOXENV%" == "" ( + SET TOXENV=py%PYTHON_VERSION_MAJOR%%PYTHON_VERSION_MINOR% + ) + IF "%PYTHON_VERSION_MAJOR%%PYTHON_VERSION_MINOR%" == "26" ( + SET TOX=python -c "from tox import cmdline; cmdline()" + ) ELSE ( + SET TOX=python -m tox + ) + IF %PYTHON_VERSION_MAJOR% == 3 ( + IF %PYTHON_VERSION_MINOR% LEQ 4 ( + :: Python 3.3 and 3.4 does not support typed-ast (mypy dependency) + %TOX% --sitepackages -e %TOXENV%-test,%TOXENV%-lint,cov || EXIT 1 + ) ELSE ( + %TOX% --sitepackages -e %TOXENV%-test,%TOXENV%-type,%TOXENV%-lint,cov || EXIT 1 + ) + ) ELSE ( + %TOX% --sitepackages -e %TOXENV%-test,%TOXENV%-lint,cov || EXIT 1 + ) +GOTO :eof + +:download +IF NOT EXIST %2 ( + IF NOT EXIST .downloads\ mkdir .downloads + powershell -command "(new-object net.webclient).DownloadFile('%1', '%2')" || EXIT 1 + +) +GOTO :eof diff --git a/tox.ini b/tox.ini index 19c2274..9e5e3be 100644 --- a/tox.ini +++ b/tox.ini @@ -47,8 +47,8 @@ commands = [testenv:mypy] deps = colorama==0.3.7 - mypy==0.501 lxml==3.7.3 + mypy==0.501 commands = mypy \ --show-error-context \ From cab83f837aeb811ea70474997cb686b3ee66270e Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Fri, 31 Mar 2017 02:48:51 +0300 Subject: [PATCH 29/45] Update to Xcode 8.3. --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 30191d0..5e39fa0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -29,7 +29,7 @@ matrix: env: PY_VER=py26,py27,py33,py34,py35,py36 PY_ORIGIN=pyenv # macOS 10.12 Sierra - os: osx - osx_image: xcode8.2 + osx_image: xcode8.3 language: generic env: PY_VER=py26,py27,py33,py34,py35,py36 # Mac OS X 10.11 El Capitan From f33060827825d491e235de045d6b79773f934d70 Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Fri, 31 Mar 2017 13:51:05 +0300 Subject: [PATCH 30/45] Test with pypy and pypy3 environments. --- .travis.yml | 17 +++++--- test/tools/ci-linux.sh | 91 +++++++++++++++++++++++++++++------------- 2 files changed, 76 insertions(+), 32 deletions(-) diff --git a/.travis.yml b/.travis.yml index 5e39fa0..b0cb261 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,34 +15,41 @@ matrix: python: 3.5 - os: linux python: 3.6 + - os: linux + python: pypy + - os: linux + python: pypy3 - os: linux python: 3.7-dev # Ubuntu 12.04 - os: linux dist: precise language: generic - env: PY_VER=py26,py27,py33,py34,py35,py36 PY_ORIGIN=pyenv + env: PY_VER=py26,py27,py33,py34,py35,py36,pypy,pypy3 PY_ORIGIN=pyenv # Ubuntu 14.04 - os: linux dist: trusty language: generic - env: PY_VER=py26,py27,py33,py34,py35,py36 PY_ORIGIN=pyenv + env: PY_VER=py26,py27,py33,py34,py35,py36,pypy,pypy3 PY_ORIGIN=pyenv # macOS 10.12 Sierra - os: osx osx_image: xcode8.3 language: generic - env: PY_VER=py26,py27,py33,py34,py35,py36 + env: PY_VER=py26,py27,py33,py34,py35,py36,pypy,pypy3 # Mac OS X 10.11 El Capitan - os: osx osx_image: xcode7.3 language: generic - env: PY_VER=py26,py27,py33,py34,py35,py36 + env: PY_VER=py26,py27,py33,py34,py35,py36,pypy,pypy3 # Mac OS X 10.10 Yosemite - os: osx osx_image: xcode6.4 language: generic - env: PY_VER=py26,py27,py33,py34,py35,py36 + env: PY_VER=py26,py27,py33,py34,py35,py36,pypy,pypy3 allow_failures: + # PyPy3 on Travis CI is out of date + - python: pypy3 + # Python nightly could fail - python: 3.7-dev - env: PY_VER=py37 - env: PY_VER=py37/pyenv diff --git a/test/tools/ci-linux.sh b/test/tools/ci-linux.sh index 543b213..9453adc 100755 --- a/test/tools/ci-linux.sh +++ b/test/tools/ci-linux.sh @@ -5,6 +5,31 @@ CI_VERBOSE=1 ci_err() { [ $1 -ne 0 ] && echo "err: $2" >&2 && exit 1; } ci_is_osx() { [ X"$(uname -s)" == X"Darwin" ]; } +ci_get_pypy_ver() { + local _v="$1" + [ -z "$_v" ] && _v=$(python -V 2>&1) + case "$_v" in + pypy-*|pypy2-*|pypy3-*|pypy3.*) echo "$_v"; return 0 ;; + pypy|pypy2|pypy3) echo "$_v-unknown"; return 0 ;; + esac + echo "$_v" | tail -1 | grep -qi pypy + if [ $? -eq 0 ]; then + local _py_ver=$(echo "$_v" | head -1 | cut -d ' ' -sf 2) + local _pypy_ver=$(echo "$_v" | tail -1 | cut -d ' ' -sf 2) + [ -z "${_py_ver} " ] && _py_ver=2 + [ -z "${_pypy_ver}" ] && _pypy_ver="unknown" + case "${_py_ver}" in + 2*) echo "pypy-${_pypy_ver}" ;; + 3.3*) echo "pypy3.3-${_pypy_ver}" ;; + 3.5*) echo "pypy3.5-${_pypy_ver}" ;; + *) echo "pypy3-${_pypy_ver}" ;; + esac + return 0 + else + return 1 + fi +} + ci_get_py_ver() { local _v case "$1" in @@ -17,39 +42,38 @@ ci_get_py_ver() { py37) _v=3.7-dev ;; pypy) ci_is_osx && _v=pypy2-5.7.0 || _v=pypy-portable-5.7.0 ;; pypy3) ci_is_osx && _v=pypy3.3-5.5-alpha || _v=pypy3-portable-5.7.0 ;; - *) [ -z "$1" ] && _v=$(python -V 2>&1 | cut -d ' ' -f 2) || _v="$1" ;; + *) + [ -z "$1" ] && set -- "$(python -V 2>&1)" + _v=$(ci_get_pypy_ver "$1") + [ -z "$_v" ] && _v=$(echo "$_v" | head -1 | cut -d ' ' -sf 2) + ;; esac echo "${_v}" + return 0 } ci_get_py_env() { - if [ -z "$1" ]; then - set -- "$(python -V 2>&1)" - fi - case "$1" in + [ -z "$1" ] && set -- "$(python -V 2>&1)" + case "$(ci_get_pypy_ver "$1")" in pypy|pypy2|pypy-*|pypy2-*) echo "pypy" ;; pypy3|pypy3*) echo "pypy3" ;; *) local _v=$(echo "$1" | head -1 | sed -e 's/[^0-9]//g' | cut -c1-2;) - echo "$1" | tail -1 | grep -qi pypy - if [ $? -eq 0 ]; then - case "${_ver}" in - 2*) echo "pypy" ;; - *) echo "pypy3" ;; - esac - else - echo "py${_v}" - fi + echo "py${_v}" esac + return 0 } ci_pyenv_setup() { + [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] install pyenv" rm -rf ~/.pyenv git clone --depth 1 https://github.com/yyuu/pyenv.git ~/.pyenv PYENV_ROOT=$HOME/.pyenv PATH="$HOME/.pyenv/bin:$PATH" eval "$(pyenv init -)" - [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] $(pyenv -v)" + ci_err $? "failed to init pyenv" + [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] pyenv init: $(pyenv -v 2>&1)" + return 0 } ci_pyenv_install() { @@ -62,19 +86,19 @@ ci_pyenv_install() { case "${_py_env}" in py37) _nocache=1 ;; esac + [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] pyenv install: ${_py_env}/${_py_ver}" [ -z "${PYENV_ROOT}" ] && PYENV_ROOT="$HOME/.pyenv" local _py_ver_dir="${PYENV_ROOT}/versions/${_py_ver}" local _py_ver_cached_dir="${CI_PYENV_CACHE}/${_py_ver}" if [ -z "${_nocache}" ]; then if [ ! -d "${_py_ver_dir}" ]; then if [ -d "${_py_ver_cached_dir}" ]; then - [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] using cached pyenv ${_py_ver}" + [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] pyenv reuse ${_py_ver}" ln -s "${_py_ver_cached_dir}" "${_py_ver_dir}" fi fi fi if [ ! -d "${_py_ver_dir}" ]; then - [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] pyenv install ${_py_ver}" pyenv install -s "${_py_ver}" ci_err $? "pyenv failed to install ${_py_ver}" if [ -z "${_nocache}" ]; then @@ -86,6 +110,7 @@ ci_pyenv_install() { fi fi pyenv rehash + return 0 } ci_pyenv_use() { @@ -94,12 +119,14 @@ ci_pyenv_use() { local _py_ver=$(ci_get_py_ver "$1") pyenv shell "${_py_ver}" ci_err $? "pyenv could not use ${_py_ver}" - [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] using python: $(python -V 2>&1)" + [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] pyenv using python: $(python -V 2>&1)" + return 0 } ci_pip_setup() { local _py_ver=$(ci_get_py_ver "$1") local _py_env=$(ci_get_py_env "${_py_ver}") + [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] install pip/venv for ${_py_env}/${_py_ver}" PIPOPT=$(python -c 'import sys; print("" if hasattr(sys, "real_prefix") else "--user")') if [ -z "${_py_env##py2*}" ]; then curl -O https://bootstrap.pypa.io/get-pip.py @@ -111,11 +138,13 @@ ci_pip_setup() { else python -m pip install ${PIPOPT} -U pip virtualenv fi + ci_err $? "failed to upgrade pip/venv" || return 0 } ci_venv_setup() { local _py_ver=$(ci_get_py_ver "$1") local _py_env=$(ci_get_py_env "${_py_ver}") + [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] create venv for ${_py_env}/${_py_ver}" local VENV_DIR=~/.venv/${_py_ver} mkdir -p -- ~/.venv rm -rf -- "${VENV_DIR}" @@ -124,6 +153,7 @@ ci_venv_setup() { else python -m virtualenv "${VENV_DIR}" fi + ci_err $? "failed to create venv" || return 0 } ci_venv_use() { @@ -131,7 +161,9 @@ ci_venv_use() { local _py_env=$(ci_get_py_env "${_py_ver}") local VENV_DIR=~/.venv/${_py_ver} . "${VENV_DIR}/bin/activate" - [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] using python: $(python -V 2>&1)" + ci_err $? "could not actiavte virtualenv" + [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] venv using python: $(python -V 2>&1)" + return 0 } ci_run_wrapped() { @@ -141,8 +173,9 @@ ci_run_wrapped() { local _v=$(echo "$_i" | cut -d '/' -f 1) local _o=$(echo "$_i" | cut -d '/' -sf 2) [ -z "${_o}" ] && _o="${PY_ORIGIN}" - eval "$1" "${_v}" "${_o}" + eval "$1" "${_v}" "${_o}" || return 1 done + return 0 } ci_step_before_install_wrapped() { @@ -154,12 +187,13 @@ ci_step_before_install_wrapped() { ci_pyenv_setup CI_PYENV_SETUP=1 fi - ci_pyenv_install "${_py_ver}" - ci_pyenv_use "${_py_ver}" + ci_pyenv_install "${_py_ver}" || return 1 + ci_pyenv_use "${_py_ver}" || return 1 ;; esac - ci_pip_setup "${_py_ver}" - ci_venv_setup "${_py_ver}" + ci_pip_setup "${_py_ver}" || return 1 + ci_venv_setup "${_py_ver}" || return 1 + return 0 } ci_step_before_install() { @@ -171,24 +205,26 @@ ci_step_before_install() { PY_ORIGIN=pyenv fi CI_PYENV_SETUP=0 - ci_run_wrapped "ci_step_before_install_wrapped" + ci_run_wrapped "ci_step_before_install_wrapped" || return 1 if [ "${CI_PYENV_SETUP}" -eq 1 ]; then pyenv shell --unset [ ${CI_VERBOSE} -gt 0 ] && pyenv versions fi + return 0 } ci_step_install_wrapped() { local _py_ver="$1" ci_venv_use "${_py_ver}" pip install -U tox coveralls codecov + ci_err $? "failed to install dependencies" || return 0 } ci_step_script_wrapped() { local _py_ver="$1" local _py_ori="$2" local _py_env=$(ci_get_py_env "${_py_ver}") - ci_venv_use "${_py_ver}" + ci_venv_use "${_py_ver}" || return 1 if [ -z "${_py_env##*py3*}" ]; then if [ -z "${_py_env##*pypy3*}" ]; then # NOTE: workaround for travis environment @@ -205,12 +241,13 @@ ci_step_script_wrapped() { TOXENV=${_py_env}-test,${_py_env}-lint fi tox -e $TOXENV,cov + ci_err $? "tox failed" || return 0 } ci_step_success_wrapped() { local _py_ver="$1" local _py_ori="$2" - ci_venv_use "${_py_ver}" + ci_venv_use "${_py_ver}" || return 1 coveralls codecov } From 9fe69841eb729fe7e8357bd39a12949e0f02bb5b Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Wed, 5 Apr 2017 00:56:17 +0300 Subject: [PATCH 31/45] Integrate SonarQube analysis. --- .gitignore | 8 +-- .travis.yml | 2 + test/tools/ci-linux.sh | 154 ++++++++++++++++++++++++++++++++++++++++- tox.ini | 10 +-- 4 files changed, 164 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 0edf4f4..9dc68e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ *~ *.pyc -html/ -venv/ +venv*/ .cache/ .tox -.coverage -coverage.xml +.coverage* +reports/ +.scannerwork/ diff --git a/.travis.yml b/.travis.yml index b0cb261..08daa94 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,6 +7,7 @@ matrix: python: 2.6 - os: linux python: 2.7 + env: SQ=1 - os: linux python: 3.3 - os: linux @@ -60,6 +61,7 @@ cache: - pip - directories: - $HOME/.pyenv.cache + - $HOME/.bin before_install: - source test/tools/ci-linux.sh diff --git a/test/tools/ci-linux.sh b/test/tools/ci-linux.sh index 9453adc..ba423e5 100755 --- a/test/tools/ci-linux.sh +++ b/test/tools/ci-linux.sh @@ -2,7 +2,8 @@ CI_VERBOSE=1 -ci_err() { [ $1 -ne 0 ] && echo "err: $2" >&2 && exit 1; } +ci_err_msg() { echo "[ci] error: $1" >&2; } +ci_err() { [ $1 -ne 0 ] && ci_err_msg "$2" && exit 1; } ci_is_osx() { [ X"$(uname -s)" == X"Darwin" ]; } ci_get_pypy_ver() { @@ -58,7 +59,7 @@ ci_get_py_env() { pypy|pypy2|pypy-*|pypy2-*) echo "pypy" ;; pypy3|pypy3*) echo "pypy3" ;; *) - local _v=$(echo "$1" | head -1 | sed -e 's/[^0-9]//g' | cut -c1-2;) + local _v=$(echo "$1" | head -1 | sed -e 's/[^0-9]//g' | cut -c1-2) echo "py${_v}" esac return 0 @@ -166,6 +167,152 @@ ci_venv_use() { return 0 } +ci_get_filedir() { + local _sdir=$(cd -- "$(dirname "$0")" && pwd) + local _pdir=$(pwd) + if [ -z "${_pdir##${_sdir}*}" ]; then + _sdir="${_pdir}" + fi + local _first=1 + while [ X"${_sdir}" != X"/" ]; do + if [ ${_first} -eq 1 ]; then + _first=0 + local _f=$(find "${_sdir}" -name "$1" | head -1) + if [ -n "${_f}" ]; then + echo $(dirname -- "${_f}") + return 0 + fi + else + _f=$(find "${_sdir}" -mindepth 1 -maxdepth 1 -name "$1" | head -1) + fi + [ -n "${_f}" ] && echo "${_sdir}" && return 0 + _sdir=$(cd -- "${_sdir}/.." && pwd) + done + return 1 +} + +ci_sq_ensure_java() { + type java >/dev/null 2>&1 + if [ $? -ne 0 ]; then + ci_err_msg "java not found" + return 1 + fi + local _java_ver=$(java -version 2>&1 | head -1 | sed -e 's/[^0-9\._]//g') + if [ -z "${_java_ver##1.8*}" ]; then + return 0 + fi + ci_err_msg "unsupported java version: ${_java_ver}" + return 1 +} + +ci_sq_ensure_scanner() { + local _cli_version="3.0.0.702" + local _cli_basedir="$HOME/.bin" + local _cli_postfix="" + case "$(uname -s)" in + Linux) + [ X"$(uname -m)" = X"x86_64" ] && _cli_postfix="-linux" + [ X"$(uname -m)" = X"amd64" ] && _cli_postfix="-linux" + ;; + Darwin) _cli_postfix="-macosx" ;; + esac + if [ X"${_cli_postfix}" = X"" ]; then + ci_sq_ensure_java || return 1 + fi + if [ X"${SONAR_SCANNER_PATH}" != X"" ]; then + if [ -e "${SONAR_SCANNER_PATH}" ]; then + return 0 + fi + fi + local _cli_fname="sonar-scanner-cli-${_cli_version}${_cli_postfix}" + [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] ensure scanner ${_cli_fname}" + local _cli_dname="sonar-scanner-${_cli_version}${_cli_postfix}" + local _cli_archive="${_cli_basedir}/${_cli_fname}.zip" + local _cli_dir="${_cli_basedir}/${_cli_dname}" + local _cli_url="https://sonarsource.bintray.com/Distribution/sonar-scanner-cli/${_cli_fname}.zip" + if [ ! -e "${_cli_archive}" ]; then + mkdir -p -- "${_cli_basedir}" > /dev/null 2>&1 + if [ $? -ne 0 ]; then + ci_err_msg "could not create ${_cli_basedir}" + return 1 + fi + [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] downloading ${_cli_fname}" + curl -kL -o "${_cli_archive}" "${_cli_url}" + [ $? -ne 0 ] && ci_err_msg "download failed" && return 1 + [ ! -e "${_cli_archive}" ] && ci_err_msg "download verify" && return 1 + fi + if [ ! -d "${_cli_dir}" ]; then + [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] extracting ${_cli_fname}" + unzip -od "${_cli_basedir}" "${_cli_archive}" + [ $? -ne 0 ] && ci_err_msg "extract failed" && return 1 + [ ! -d "${_cli_dir}" ] && ci_err_msg "extract verify" && return 1 + fi + if [ ! -e "${_cli_dir}/bin/sonar-scanner" ]; then + ci_err_msg "sonar-scanner binary not found." + return 1 + fi + SONAR_SCANNER_PATH="${_cli_dir}/bin/sonar-scanner" + return 0 +} + +ci_sq_run() { + if [ X"${SONAR_SCANNER_PATH}" = X"" ]; then + ci_err_msg "environment variable SONAR_SCANNER_PATH not set" + return 1 + fi + if [ X"${SONAR_HOST_URL}" = X"" ]; then + ci_err_msg "environment variable SONAR_HOST_URL not set" + return 1 + fi + if [ X"${SONAR_AUTH_TOKEN}" = X"" ]; then + ci_err_msg "environment variable SONAR_AUTH_TOKEN not set" + return 1 + fi + local _pdir=$(ci_get_filedir "ssh-audit.py") + if [ -z "${_pdir}" ]; then + ci_err_msg "failed to find project directory" + return 1 + fi + local _odir=$(pwd) + cd -- "${_pdir}" + local _branch=$(git name-rev --name-only HEAD | cut -d '~' -f 1) + case "${_branch}" in + master) ;; + develop) ;; + *) ci_err_msg "unknown branch: ${_branch}"; return 1 ;; + esac + local _junit=$(cd -- "${_pdir}" && ls -1 reports/junit.*.xml | sort -r | head -1) + if [ X"${_junit}" = X"" ]; then + ci_err_msg "no junit.xml found" + return 1 + fi + local _project_ver=$(grep VERSION ssh-audit.py | head -1 | cut -d "'" -f 2) + if [ -z "${_project_ver}" ]; then + ci_err_msg "failed to get project version" + return 1 + fi + if [ -z "${_project_ver##*dev}" ]; then + local _git_rc=$(git rev-list --count `git rev-parse HEAD`) + _project_ver="${_project_ver}.${_git_rc}" + fi + [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] run sonar-scanner for ${_project_ver}" + "${SONAR_SCANNER_PATH}" -X \ + -Dsonar.projectKey=arthepsy-github:ssh-audit \ + -Dsonar.sources=ssh-audit.py \ + -Dsonar.tests=test \ + -Dsonar.test.inclusions=test/*.py \ + -Dsonar.host.url="${SONAR_HOST_URL}" \ + -Dsonar.projectName=ssh-audit \ + -Dsonar.projectVersion="${_project_ver}" \ + -Dsonar.branch="${_branch}" \ + -Dsonar.python.coverage.overallReportPath=reports/coverage.xml \ + -Dsonar.python.xunit.reportPath="${_junit}" \ + -Dsonar.organization=arthepsy-github \ + -Dsonar.login="${SONAR_AUTH_TOKEN}" + cd -- "${_odir}" + return 0 +} + ci_run_wrapped() { local _versions=$(echo "${PY_VER}" | sed -e 's/,/ /g') [ -z "${_versions}" ] && eval "$1" @@ -247,6 +394,9 @@ ci_step_script_wrapped() { ci_step_success_wrapped() { local _py_ver="$1" local _py_ori="$2" + if [ X"${SQ}" = X"1" ]; then + ci_sq_ensure_scanner && ci_sq_run + fi ci_venv_use "${_py_ver}" || return 1 coveralls codecov diff --git a/tox.ini b/tox.ini index 9e5e3be..4bb7ef5 100644 --- a/tox.ini +++ b/tox.ini @@ -20,11 +20,12 @@ setenv = SSHAUDIT = {toxinidir}/ssh-audit.py test: COVERAGE_FILE = {toxinidir}/.coverage.{envname} type,mypy: MYPYPATH = {toxinidir}/test/stubs - type,mypy: MYPYHTML = {toxinidir}/html/mypy + type,mypy: MYPYHTML = {toxinidir}/reports/html/mypy commands = - test: coverage run --source ssh-audit -m -- pytest -v {posargs:test} + test: coverage run --source ssh-audit -m -- \ + test: pytest -v --junitxml={toxinidir}/reports/junit.{envname}.xml {posargs:test} test: coverage report --show-missing - test: - coverage html -d {toxinidir}/html/coverage.{envname} + test: coverage html -d {toxinidir}/reports/html/coverage.{envname} py{33,34,35,36,37}-{type,mypy}: {[testenv:mypy]commands} py{27,py,py3,33,34,35,36,37}-{lint,pylint}: {[testenv:pylint]commands} py{27,py,py3,33,34,35,36,37}-{lint,flake8}: {[testenv:flake8]commands} @@ -42,7 +43,8 @@ commands = coverage erase coverage combine coverage report --show-missing - - coverage html -d {toxinidir}/html/coverage + coverage xml -i -o {toxinidir}/reports/coverage.xml + coverage html -d {toxinidir}/reports/html/coverage [testenv:mypy] deps = From 464bb154f30f4f7b8f8d465295c1d6665dde47bd Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Wed, 5 Apr 2017 04:25:01 +0300 Subject: [PATCH 32/45] Use git commit as dev version suffix. Add badge. --- README.md | 2 +- test/tools/ci-linux.sh | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d709a1a..65281c2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![travis build status](https://api.travis-ci.org/arthepsy/ssh-audit.svg?branch=develop)](https://travis-ci.org/arthepsy/ssh-audit) [![appveyor build status](https://ci.appveyor.com/api/projects/status/4m5r73m0r023edil/branch/develop?svg=true)](https://ci.appveyor.com/project/arthepsy/ssh-audit) [![codecov](https://codecov.io/gh/arthepsy/ssh-audit/branch/develop/graph/badge.svg)](https://codecov.io/gh/arthepsy/ssh-audit) -[![coverage status](https://coveralls.io/repos/github/arthepsy/ssh-audit/badge.svg)](https://coveralls.io/github/arthepsy/ssh-audit) +[![Quality Gate](https://sonarqube.com/api/badges/gate?key=arthepsy-github%3Assh-audit%3Adevelop&template=ROUNDED)](https://sq.evolutiongaming.com/dashboard?id=arthepsy-github%3Assh-audit%3Adevelop) **ssh-audit** is a tool for ssh server auditing. ## Features diff --git a/test/tools/ci-linux.sh b/test/tools/ci-linux.sh index ba423e5..0bb0253 100755 --- a/test/tools/ci-linux.sh +++ b/test/tools/ci-linux.sh @@ -292,8 +292,8 @@ ci_sq_run() { return 1 fi if [ -z "${_project_ver##*dev}" ]; then - local _git_rc=$(git rev-list --count `git rev-parse HEAD`) - _project_ver="${_project_ver}.${_git_rc}" + local _git_commit=$(git rev-parse --short=8 HEAD) + _project_ver="${_project_ver}.${_git_commit}" fi [ ${CI_VERBOSE} -gt 0 ] && echo "[ci] run sonar-scanner for ${_project_ver}" "${SONAR_SCANNER_PATH}" -X \ From 09c2e7b2d59b10ddf0d6ab20ead2c40ee52a4250 Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Wed, 5 Apr 2017 04:27:39 +0300 Subject: [PATCH 33/45] Fix SonarQube python:S1871. --- ssh-audit.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/ssh-audit.py b/ssh-audit.py index e1684f7..9fcd04c 100755 --- a/ssh-audit.py +++ b/ssh-audit.py @@ -1192,11 +1192,9 @@ def get_timeframe(cls, versions, for_server=True, result=None): if ssh_prefix not in result: result[ssh_prefix] = [None, None, None] prev, push = result[ssh_prefix][i], False - if prev is None: - push = True - elif i == 0 and prev < ssh_version: - push = True - elif i > 0 and prev > ssh_version: + if ((prev is None) or + (prev < ssh_version and i == 0) or + (prev > ssh_version and i > 0)): push = True if push: result[ssh_prefix][i] = ssh_version From bb122ffe139ced0825cbb3965ee1cf5166ea4b29 Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Wed, 5 Apr 2017 16:02:40 +0300 Subject: [PATCH 34/45] Replace assertions with exceptions. --- ssh-audit.py | 12 +++++++----- test/test_ssh1.py | 41 +++++++++++++++++++++++++++++------------ 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/ssh-audit.py b/ssh-audit.py index 9fcd04c..50a7d0b 100755 --- a/ssh-audit.py +++ b/ssh-audit.py @@ -570,8 +570,10 @@ class KexDB(object): # pylint: disable=too-few-public-methods class PublicKeyMessage(object): def __init__(self, cookie, skey, hkey, pflags, cmask, amask): # type: (binary_type, Tuple[int, int, int], Tuple[int, int, int], int, int, int) -> None - assert len(skey) == 3 - assert len(hkey) == 3 + if len(skey) != 3: + raise ValueError('invalid server key pair: {0}'.format(skey)) + if len(hkey) != 3: + raise ValueError('invalid host key pair: {0}'.format(hkey)) self.__cookie = cookie self.__server_key = skey self.__host_key = hkey @@ -1192,9 +1194,9 @@ def get_timeframe(cls, versions, for_server=True, result=None): if ssh_prefix not in result: result[ssh_prefix] = [None, None, None] prev, push = result[ssh_prefix][i], False - if ((prev is None) or - (prev < ssh_version and i == 0) or - (prev > ssh_version and i > 0)): + if (prev is None or + (prev < ssh_version and i == 0) or + (prev > ssh_version and i > 0)): push = True if push: result[ssh_prefix][i] = ssh_version diff --git a/test/test_ssh1.py b/test/test_ssh1.py index 0f62983..f18e4be 100644 --- a/test/test_ssh1.py +++ b/test/test_ssh1.py @@ -66,34 +66,51 @@ def test_fingerprint(self): assert fp.md5 == 'MD5:9d:26:f8:39:fc:20:9d:9b:ca:cc:4a:0f:e1:93:f5:96' assert fp.sha256 == 'SHA256:vZdx3mhzbvVJmn08t/ruv8WDhJ9jfKYsCTuSzot+QIs' - def test_pkm_read(self): - pkm = self.ssh1.PublicKeyMessage.parse(self._pkm_payload()) - assert pkm is not None - assert pkm.cookie == b'\x88\x99\xaa\xbb\xcc\xdd\xee\xff' - b, e, m = self._server_key() + def _assert_pkm_keys(self, pkm, skey, hkey): + b, e, m = skey assert pkm.server_key_bits == b assert pkm.server_key_public_exponent == e assert pkm.server_key_public_modulus == m - b, e, m = self._host_key() + b, e, m = hkey assert pkm.host_key_bits == b assert pkm.host_key_public_exponent == e assert pkm.host_key_public_modulus == m - fp = self.ssh.Fingerprint(pkm.host_key_fingerprint_data) + + def _assert_pkm_fields(self, pkm, skey, hkey): + assert pkm is not None + assert pkm.cookie == b'\x88\x99\xaa\xbb\xcc\xdd\xee\xff' + self._assert_pkm_keys(pkm, skey, hkey) assert pkm.protocol_flags == 2 assert pkm.supported_ciphers_mask == 72 assert pkm.supported_ciphers == ['3des', 'blowfish'] assert pkm.supported_authentications_mask == 36 assert pkm.supported_authentications == ['rsa', 'tis'] + fp = self.ssh.Fingerprint(pkm.host_key_fingerprint_data) assert fp.md5 == 'MD5:9d:26:f8:39:fc:20:9d:9b:ca:cc:4a:0f:e1:93:f5:96' assert fp.sha256 == 'SHA256:vZdx3mhzbvVJmn08t/ruv8WDhJ9jfKYsCTuSzot+QIs' + def test_pkm_init(self): + cookie = b'\x88\x99\xaa\xbb\xcc\xdd\xee\xff' + pflags, cmask, amask = 2, 72, 36 + skey, hkey = self._server_key(), self._host_key() + pkm = self.ssh1.PublicKeyMessage(cookie, skey, hkey, pflags, cmask, amask) + self._assert_pkm_fields(pkm, skey, hkey) + for skey2 in ([], [0], [0,1], [0,1,2,3]): + with pytest.raises(ValueError): + pkm = self.ssh1.PublicKeyMessage(cookie, skey2, hkey, pflags, cmask, amask) + for hkey2 in ([], [0], [0,1], [0,1,2,3]): + with pytest.raises(ValueError): + print(hkey2) + pkm = self.ssh1.PublicKeyMessage(cookie, skey, hkey2, pflags, cmask, amask) + + def test_pkm_read(self): + pkm = self.ssh1.PublicKeyMessage.parse(self._pkm_payload()) + self._assert_pkm_fields(pkm, self._server_key(), self._host_key()) + def test_pkm_payload(self): cookie = b'\x88\x99\xaa\xbb\xcc\xdd\xee\xff' - skey = self._server_key() - hkey = self._host_key() - pflags = 2 - cmask = 72 - amask = 36 + skey, hkey = self._server_key(), self._host_key() + pflags, cmask, amask = 2, 72, 36 pkm1 = self.ssh1.PublicKeyMessage(cookie, skey, hkey, pflags, cmask, amask) pkm2 = self.ssh1.PublicKeyMessage.parse(self._pkm_payload()) assert pkm1.payload == pkm2.payload From c132c62b96e9909b34f665da0002e29c7cd224b0 Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Wed, 5 Apr 2017 16:13:35 +0300 Subject: [PATCH 35/45] Remove useless parentheses. --- ssh-audit.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/ssh-audit.py b/ssh-audit.py index 50a7d0b..8ccd3ff 100755 --- a/ssh-audit.py +++ b/ssh-audit.py @@ -819,7 +819,7 @@ def _create_mpint(cls, n, signed=True, bits=None): ql = (length + 7) // 8 fmt, v2 = '>{0}Q'.format(ql), [0] * ql for i in range(ql): - v2[ql - i - 1] = (n & 0xffffffffffffffff) + v2[ql - i - 1] = n & 0xffffffffffffffff n >>= 64 data = bytes(struct.pack(fmt, *v2)[-length:]) if not signed: @@ -1162,11 +1162,11 @@ class Algorithm(object): def get_ssh_version(version_desc): # type: (str) -> Tuple[str, str] if version_desc.startswith('d'): - return (SSH.Product.DropbearSSH, version_desc[1:]) + return SSH.Product.DropbearSSH, version_desc[1:] elif version_desc.startswith('l1'): - return (SSH.Product.LibSSH, version_desc[2:]) + return SSH.Product.LibSSH, version_desc[2:] else: - return (SSH.Product.OpenSSH, version_desc) + return SSH.Product.OpenSSH, version_desc @classmethod def get_timeframe(cls, versions, for_server=True, result=None): @@ -1550,9 +1550,9 @@ def _resolve(self, ipvo): if prefer_ipvo: r = sorted(r, key=lambda x: x[0], reverse=not prefer_ipv4) check = any(stype == rline[2] for rline in r) - for (af, socktype, _proto, _canonname, addr) in r: + for af, socktype, _proto, _canonname, addr in r: if not check or socktype == socket.SOCK_STREAM: - yield (af, addr) + yield af, addr except socket.error as e: out.fail('[exception] {0}'.format(e)) sys.exit(1) @@ -1560,7 +1560,7 @@ def _resolve(self, ipvo): def connect(self, ipvo=(), cto=3.0, rto=5.0): # type: (Sequence[int], float, float) -> None err = None - for (af, addr) in self._resolve(ipvo): + for af, addr in self._resolve(ipvo): s = None try: s = socket.socket(af, socket.SOCK_STREAM) @@ -1614,33 +1614,33 @@ def get_banner(self, sshv=2): def recv(self, size=2048): # type: (int) -> Tuple[int, Optional[str]] if self.__sock is None: - return (-1, 'not connected') + return -1, 'not connected' try: data = self.__sock.recv(size) except socket.timeout: - return (-1, 'timed out') + return -1, 'timed out' except socket.error as e: if e.args[0] in (errno.EAGAIN, errno.EWOULDBLOCK): - return (0, 'retry') - return (-1, str(e.args[-1])) + return 0, 'retry' + return -1, str(e.args[-1]) if len(data) == 0: - return (-1, None) + return -1, None pos = self._buf.tell() self._buf.seek(0, 2) self._buf.write(data) self._len += len(data) self._buf.seek(pos, 0) - return (len(data), None) + return len(data), None def send(self, data): # type: (binary_type) -> Tuple[int, Optional[str]] if self.__sock is None: - return (-1, 'not connected') + return -1, 'not connected' try: self.__sock.send(data) - return (0, None) + return 0, None except socket.error as e: - return (-1, str(e.args[-1])) + return -1, str(e.args[-1]) self.__sock.send(data) def send_banner(self, banner): @@ -1665,7 +1665,7 @@ def read_packet(self, sshv=2): header.write_int(packet_length) # XXX: validate length if sshv == 1: - padding_length = (8 - packet_length % 8) + padding_length = 8 - packet_length % 8 self.ensure_read(padding_length) padding = self.read(padding_length) header.write(padding) @@ -1706,7 +1706,7 @@ def read_packet(self, sshv=2): e = header.write_flush().strip() else: e = ex.args[0].encode('utf-8') - return (-1, e) + return -1, e def send_packet(self): # type: () -> Tuple[int, Optional[str]] @@ -1826,7 +1826,7 @@ def output_algorithm(alg_db, alg_type, alg_name, alg_max_len=0): else: texts.append(('warn', 'unknown algorithm')) first = True - for (level, text) in texts: + for level, text in texts: f = getattr(out, level) text = '[' + level + '] ' + text if first: From e4bdabb8916f8e8a932eae808a2e64cca64ce5e4 Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Wed, 5 Apr 2017 16:31:43 +0300 Subject: [PATCH 36/45] Fix method type and naming. --- ssh-audit.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/ssh-audit.py b/ssh-audit.py index 8ccd3ff..d238925 100755 --- a/ssh-audit.py +++ b/ssh-audit.py @@ -198,6 +198,7 @@ def __init__(self): self.colors = True self.verbose = False self.__minlevel = 0 + self.__colsupport = 'colorama' in sys.modules or os.name == 'posix' @property def minlevel(self): @@ -226,7 +227,7 @@ def sep(self): @property def colors_supported(self): # type: () -> bool - return 'colorama' in sys.modules or os.name == 'posix' + return self.__colsupport @staticmethod def _colorized(color): @@ -1270,19 +1271,19 @@ def values(self): @property def maxlen(self): # type: () -> int - def ml(items): + def _ml(items): # type: (Sequence[text_type]) -> int return max(len(i) for i in items) maxlen = 0 if self.ssh1kex is not None: - maxlen = max(ml(self.ssh1kex.supported_ciphers), - ml(self.ssh1kex.supported_authentications), + maxlen = max(_ml(self.ssh1kex.supported_ciphers), + _ml(self.ssh1kex.supported_authentications), maxlen) if self.ssh2kex is not None: - maxlen = max(ml(self.ssh2kex.kex_algorithms), - ml(self.ssh2kex.key_algorithms), - ml(self.ssh2kex.server.encryption), - ml(self.ssh2kex.server.mac), + maxlen = max(_ml(self.ssh2kex.kex_algorithms), + _ml(self.ssh2kex.key_algorithms), + _ml(self.ssh2kex.server.encryption), + _ml(self.ssh2kex.server.mac), maxlen) return maxlen From 0d555d43b327a8e2332fc659b46ad134693841a8 Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Wed, 5 Apr 2017 18:12:26 +0300 Subject: [PATCH 37/45] Condition must be a boolean fixes. --- ssh-audit.py | 46 +++++++++++++++++++++++----------------------- tox.ini | 2 +- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/ssh-audit.py b/ssh-audit.py index d238925..9fd4246 100755 --- a/ssh-audit.py +++ b/ssh-audit.py @@ -702,8 +702,8 @@ class ReadBuf(object): def __init__(self, data=None): # type: (Optional[binary_type]) -> None super(ReadBuf, self).__init__() - self._buf = BytesIO(data) if data else BytesIO() - self._len = len(data) if data else 0 + self._buf = BytesIO(data) if data is not None else BytesIO() + self._len = len(data) if data is not None else 0 @property def unread_len(self): @@ -739,13 +739,13 @@ def read_string(self): return self.read(n) @classmethod - def _parse_mpint(cls, v, pad, sf): + def _parse_mpint(cls, v, pad, f): # type: (binary_type, binary_type, str) -> int r = 0 - if len(v) % 4: + if len(v) % 4 != 0: v = pad * (4 - (len(v) % 4)) + v for i in range(0, len(v), 4): - r = (r << 32) | struct.unpack(sf, v[i:i + 4])[0] + r = (r << 32) | struct.unpack(f, v[i:i + 4])[0] return r def read_mpint1(self): @@ -761,8 +761,8 @@ def read_mpint2(self): v = self.read_string() if len(v) == 0: return 0 - pad, sf = (b'\xff', '>i') if ord(v[0:1]) & 0x80 else (b'\x00', '>I') - return self._parse_mpint(v, pad, sf) + pad, f = (b'\xff', '>i') if ord(v[0:1]) & 0x80 != 0 else (b'\x00', '>I') + return self._parse_mpint(v, pad, f) def read_line(self): # type: () -> text_type @@ -773,7 +773,7 @@ class WriteBuf(object): def __init__(self, data=None): # type: (Optional[binary_type]) -> None super(WriteBuf, self).__init__() - self._wbuf = BytesIO(data) if data else BytesIO() + self._wbuf = BytesIO(data) if data is not None else BytesIO() def write(self, data): # type: (binary_type) -> WriteBuf @@ -916,7 +916,7 @@ def compare_version(self, other): else: other = str(other) mx = re.match(r'^([\d\.]+\d+)(.*)$', other) - if mx: + if mx is not None: oversion, opatch = mx.group(1), mx.group(2).strip() else: oversion, opatch = other, '' @@ -934,9 +934,9 @@ def compare_version(self, other): mx1 = re.match(r'^p\d(.*)', opatch) mx2 = re.match(r'^p\d(.*)', spatch) if not (mx1 and mx2): - if mx1: + if mx1 is not None: opatch = mx1.group(1) - if mx2: + if mx2 is not None: spatch = mx2.group(1) if spatch < opatch: return -1 @@ -1009,19 +1009,19 @@ def _extract_os_version(cls, c): if c is None: return None mx = re.match(r'^NetBSD(?:_Secure_Shell)?(?:[\s-]+(\d{8})(.*))?$', c) - if mx: + if mx is not None: d = cls._fix_date(mx.group(1)) return 'NetBSD' if d is None else 'NetBSD ({0})'.format(d) mx = re.match(r'^FreeBSD(?:\slocalisations)?[\s-]+(\d{8})(.*)$', c) - if not mx: + if mx is None: mx = re.match(r'^[^@]+@FreeBSD\.org[\s-]+(\d{8})(.*)$', c) - if mx: + if mx is not None: d = cls._fix_date(mx.group(1)) return 'FreeBSD' if d is None else 'FreeBSD ({0})'.format(d) w = ['RemotelyAnywhere', 'DesktopAuthority', 'RemoteSupportManager'] for win_soft in w: mx = re.match(r'^in ' + win_soft + r' ([\d\.]+\d)$', c) - if mx: + if mx is not None: ver = mx.group(1) return 'Microsoft Windows ({0} {1})'.format(win_soft, ver) generic = ['NetBSD', 'FreeBSD'] @@ -1037,35 +1037,35 @@ def parse(cls, banner): software = str(banner.software) mx = re.match(r'^dropbear_([\d\.]+\d+)(.*)', software) v = None # type: Optional[str] - if mx: + if mx is not None: patch = cls._fix_patch(mx.group(2)) v, p = 'Matt Johnston', SSH.Product.DropbearSSH v = None return cls(v, p, mx.group(1), patch, None) mx = re.match(r'^OpenSSH[_\.-]+([\d\.]+\d+)(.*)', software) - if mx: + if mx is not None: patch = cls._fix_patch(mx.group(2)) v, p = 'OpenBSD', SSH.Product.OpenSSH v = None os_version = cls._extract_os_version(banner.comments) return cls(v, p, mx.group(1), patch, os_version) mx = re.match(r'^libssh-([\d\.]+\d+)(.*)', software) - if mx: + if mx is not None: patch = cls._fix_patch(mx.group(2)) v, p = None, SSH.Product.LibSSH os_version = cls._extract_os_version(banner.comments) return cls(v, p, mx.group(1), patch, os_version) mx = re.match(r'^RomSShell_([\d\.]+\d+)(.*)', software) - if mx: + if mx is not None: patch = cls._fix_patch(mx.group(2)) v, p = 'Allegro Software', 'RomSShell' return cls(v, p, mx.group(1), patch, None) mx = re.match(r'^mpSSH_([\d\.]+\d+)', software) - if mx: + if mx is not None: v, p = 'HP', 'iLO (Integrated Lights-Out) sshd' return cls(v, p, mx.group(1), None, None) mx = re.match(r'^Cisco-([\d\.]+\d+)', software) - if mx: + if mx is not None: v, p = 'Cisco', 'IOS/PIX sshd' return cls(v, p, mx.group(1), None, None) return None @@ -1959,7 +1959,7 @@ def output_recommendations(algs, software, padlen=0): def output(banner, header, kex=None, pkm=None): # type: (Optional[SSH.Banner], List[text_type], Optional[SSH2.Kex], Optional[SSH1.PublicKeyMessage]) -> None - sshv = 1 if pkm else 2 + sshv = 1 if pkm is not None else 2 algs = SSH.Algorithms(pkm, kex) with OutputBuffer() as obuf: if len(header) > 0: @@ -2168,7 +2168,7 @@ def audit(aconf, sshv=None): fmt = '[exception] did not receive {0} ({1}), ' + \ 'instead received unknown message ({2})' err = fmt.format(err_pair[0], err_pair[1], packet_type) - if err: + if err is not None: output(banner, header) out.fail(err) sys.exit(1) diff --git a/tox.ini b/tox.ini index 4bb7ef5..5e9a864 100644 --- a/tox.ini +++ b/tox.ini @@ -107,7 +107,7 @@ warn_redundant_casts = True warn_return_any = True warn_unused_ignores = True strict_optional = True -#strict_boolean = False +#strict_boolean = True [pylint] reports = no From 21a93cbd66e42c72383d7afc3ae022e3d44c3a7d Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Thu, 6 Apr 2017 05:27:13 +0300 Subject: [PATCH 38/45] Condition must be a boolean fixes. --- ssh-audit.py | 83 +++++++++++++++++++++++++++------------------------- tox.ini | 2 +- 2 files changed, 44 insertions(+), 41 deletions(-) diff --git a/ssh-audit.py b/ssh-audit.py index 9fd4246..45cbd9c 100755 --- a/ssh-audit.py +++ b/ssh-audit.py @@ -55,7 +55,7 @@ def usage(err=None): uout = Output() p = os.path.basename(sys.argv[0]) uout.head('# {0} {1}, moo@arthepsy.eu\n'.format(p, VERSION)) - if err is not None: + if err is not None and len(err) > 0: uout.fail('\n' + err) uout.info('usage: {0} [-1246pbnvl] \n'.format(p)) uout.info(' -h, --help print this help') @@ -92,10 +92,10 @@ def __setattr__(self, name, value): # type: (str, Union[str, int, bool, Sequence[int]]) -> None valid = False if name in ['ssh1', 'ssh2', 'batch', 'colors', 'verbose']: - valid, value = True, True if value else False + valid, value = True, True if bool(value) else False elif name in ['ipv4', 'ipv6']: valid = False - value = True if value else False + value = True if bool(value) else False ipv = 4 if name == 'ipv4' else 6 if value: value = tuple(list(self.ipvo) + [ipv]) @@ -916,7 +916,7 @@ def compare_version(self, other): else: other = str(other) mx = re.match(r'^([\d\.]+\d+)(.*)$', other) - if mx is not None: + if bool(mx): oversion, opatch = mx.group(1), mx.group(2).strip() else: oversion, opatch = other, '' @@ -933,10 +933,10 @@ def compare_version(self, other): elif self.product == SSH.Product.OpenSSH: mx1 = re.match(r'^p\d(.*)', opatch) mx2 = re.match(r'^p\d(.*)', spatch) - if not (mx1 and mx2): - if mx1 is not None: + if not (bool(mx1) and bool(mx2)): + if bool(mx1): opatch = mx1.group(1) - if mx2 is not None: + if bool(mx2): spatch = mx2.group(1) if spatch < opatch: return -1 @@ -946,28 +946,28 @@ def compare_version(self, other): def between_versions(self, vfrom, vtill): # type: (str, str) -> bool - if vfrom and self.compare_version(vfrom) < 0: + if bool(vfrom) and self.compare_version(vfrom) < 0: return False - if vtill and self.compare_version(vtill) > 0: + if bool(vtill) and self.compare_version(vtill) > 0: return False return True def display(self, full=True): # type: (bool) -> str - r = '{0} '.format(self.vendor) if self.vendor else '' + r = '{0} '.format(self.vendor) if bool(self.vendor) else '' r += self.product - if self.version: + if bool(self.version): r += ' {0}'.format(self.version) if full: patch = self.patch or '' if self.product == SSH.Product.OpenSSH: mx = re.match(r'^(p\d)(.*)$', patch) - if mx is not None: + if bool(mx): r += mx.group(1) patch = mx.group(2).strip() - if patch: + if bool(patch): r += ' ({0})'.format(patch) - if self.os: + if bool(self.os): r += ' running on {0}'.format(self.os) return r @@ -977,16 +977,13 @@ def __str__(self): def __repr__(self): # type: () -> str - r = 'vendor={0}'.format(self.vendor) if self.vendor else '' - if self.product: - if self.vendor: - r += ', ' - r += 'product={0}'.format(self.product) - if self.version: + r = 'vendor={0}, '.format(self.vendor) if bool(self.vendor) else '' + r += 'product={0}'.format(self.product) + if bool(self.version): r += ', version={0}'.format(self.version) - if self.patch: + if bool(self.patch): r += ', patch={0}'.format(self.patch) - if self.os: + if bool(self.os): r += ', os={0}'.format(self.os) return '<{0}({1})>'.format(self.__class__.__name__, r) @@ -1009,19 +1006,19 @@ def _extract_os_version(cls, c): if c is None: return None mx = re.match(r'^NetBSD(?:_Secure_Shell)?(?:[\s-]+(\d{8})(.*))?$', c) - if mx is not None: + if bool(mx): d = cls._fix_date(mx.group(1)) return 'NetBSD' if d is None else 'NetBSD ({0})'.format(d) mx = re.match(r'^FreeBSD(?:\slocalisations)?[\s-]+(\d{8})(.*)$', c) - if mx is None: + if not bool(mx): mx = re.match(r'^[^@]+@FreeBSD\.org[\s-]+(\d{8})(.*)$', c) - if mx is not None: + if bool(mx): d = cls._fix_date(mx.group(1)) return 'FreeBSD' if d is None else 'FreeBSD ({0})'.format(d) w = ['RemotelyAnywhere', 'DesktopAuthority', 'RemoteSupportManager'] for win_soft in w: mx = re.match(r'^in ' + win_soft + r' ([\d\.]+\d)$', c) - if mx is not None: + if bool(mx): ver = mx.group(1) return 'Microsoft Windows ({0} {1})'.format(win_soft, ver) generic = ['NetBSD', 'FreeBSD'] @@ -1037,35 +1034,35 @@ def parse(cls, banner): software = str(banner.software) mx = re.match(r'^dropbear_([\d\.]+\d+)(.*)', software) v = None # type: Optional[str] - if mx is not None: + if bool(mx): patch = cls._fix_patch(mx.group(2)) v, p = 'Matt Johnston', SSH.Product.DropbearSSH v = None return cls(v, p, mx.group(1), patch, None) mx = re.match(r'^OpenSSH[_\.-]+([\d\.]+\d+)(.*)', software) - if mx is not None: + if bool(mx): patch = cls._fix_patch(mx.group(2)) v, p = 'OpenBSD', SSH.Product.OpenSSH v = None os_version = cls._extract_os_version(banner.comments) return cls(v, p, mx.group(1), patch, os_version) mx = re.match(r'^libssh-([\d\.]+\d+)(.*)', software) - if mx is not None: + if bool(mx): patch = cls._fix_patch(mx.group(2)) v, p = None, SSH.Product.LibSSH os_version = cls._extract_os_version(banner.comments) return cls(v, p, mx.group(1), patch, os_version) mx = re.match(r'^RomSShell_([\d\.]+\d+)(.*)', software) - if mx is not None: + if bool(mx): patch = cls._fix_patch(mx.group(2)) v, p = 'Allegro Software', 'RomSShell' return cls(v, p, mx.group(1), patch, None) mx = re.match(r'^mpSSH_([\d\.]+\d+)', software) - if mx is not None: + if bool(mx): v, p = 'HP', 'iLO (Integrated Lights-Out) sshd' return cls(v, p, mx.group(1), None, None) mx = re.match(r'^Cisco-([\d\.]+\d+)', software) - if mx is not None: + if bool(mx): v, p = 'Cisco', 'IOS/PIX sshd' return cls(v, p, mx.group(1), None, None) return None @@ -1107,7 +1104,7 @@ def __str__(self): r = 'SSH-{0}.{1}'.format(self.protocol[0], self.protocol[1]) if self.software is not None: r += '-{0}'.format(self.software) - if self.comments: + if bool(self.comments): r += ' {0}'.format(self.comments) return r @@ -1115,19 +1112,19 @@ def __repr__(self): # type: () -> str p = '{0}.{1}'.format(self.protocol[0], self.protocol[1]) r = 'protocol={0}'.format(p) - if self.software: + if self.software is not None: r += ', software={0}'.format(self.software) - if self.comments: + if bool(self.comments): r += ', comments={0}'.format(self.comments) return '<{0}({1})>'.format(self.__class__.__name__, r) @classmethod def parse(cls, banner): - # type: (text_type) -> SSH.Banner + # type: (text_type) -> Optional[SSH.Banner] valid_ascii = utils.is_print_ascii(banner) ascii_banner = utils.to_print_ascii(banner) mx = cls.RX_BANNER.match(ascii_banner) - if mx is None: + if not bool(mx): return None protocol = min(re.findall(cls.RX_PROTOCOL, mx.group(1))) protocol = (int(protocol[0]), int(protocol[1])) @@ -1814,7 +1811,7 @@ def output_algorithm(alg_db, alg_type, alg_name, alg_max_len=0): if level == 'info': versions = alg_desc[0] since_text = SSH.Algorithm.get_since_text(versions) - if since_text: + if since_text is not None and len(since_text) > 0: texts.append((level, since_text)) idx = idx + 1 if ldesc > idx: @@ -1951,7 +1948,10 @@ def output_recommendations(algs, software, padlen=0): fm = '(rec) {0}{1}{2}-- {3} algorithm to {4} {5}' fn(fm.format(sg, name, p, alg_type, an, b)) if len(obuf) > 0: - title = '(for {0})'.format(software.display(False)) if software else '' + if software is not None: + title = '(for {0})'.format(software.display(False)) + else: + title = '' out.head('# algorithm recommendations {0}'.format(title)) obuf.flush() out.sep() @@ -2150,7 +2150,10 @@ def audit(aconf, sshv=None): packet_type, payload = s.read_packet(sshv) if packet_type < 0: try: - payload_txt = payload.decode('utf-8') if payload else u'empty' + if payload is not None and len(payload) > 0: + payload_txt = payload.decode('utf-8') + else: + payload_txt = u'empty' except UnicodeDecodeError: payload_txt = u'"{0}"'.format(repr(payload).lstrip('b')[1:-1]) if payload_txt == u'Protocol major versions differ.': diff --git a/tox.ini b/tox.ini index 5e9a864..add92a2 100644 --- a/tox.ini +++ b/tox.ini @@ -107,7 +107,7 @@ warn_redundant_casts = True warn_return_any = True warn_unused_ignores = True strict_optional = True -#strict_boolean = True +strict_boolean = True [pylint] reports = no From 6c8173d409b8e016d8cc788930579c80ed4a46c8 Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Thu, 6 Apr 2017 05:27:40 +0300 Subject: [PATCH 39/45] Fix to_ntext test. --- test/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_utils.py b/test/test_utils.py index 64cb07b..2a83bd8 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -78,7 +78,7 @@ def test_to_ntext_py3(self): assert self.utils.to_ntext('fran\xc3\xa7ais') == 'fran\xc3\xa7ais' assert self.utils.to_ntext(u'fran\xe7ais') == 'fran\xe7ais' # binary_type (bytes) - assert self.utils.to_utext(b'fran\xc3\xa7ais') == 'fran\xe7ais' + assert self.utils.to_ntext(b'fran\xc3\xa7ais') == 'fran\xe7ais' # other with pytest.raises(TypeError): self.utils.to_ntext(123) From 774d1c1fe479eedf32d604d3c0fdf69efcd7af3e Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Mon, 10 Apr 2017 13:20:02 +0300 Subject: [PATCH 40/45] Ignore linting long assertion lines. --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index add92a2..0655e65 100644 --- a/tox.ini +++ b/tox.ini @@ -133,7 +133,7 @@ max-public-methods = 20 max-bool-expr = 5 max-nested-blocks = 6 max-line-length = 80 -ignore-long-lines = ^\s*(#\s+type:\s+.*|[A-Z0-9_]+\s+=\s+.*|('.*':\s+)?\[.*\],?)$ +ignore-long-lines = ^\s*(#\s+type:\s+.*|[A-Z0-9_]+\s+=\s+.*|('.*':\s+)?\[.*\],?|assert\s+.*)$ max-module-lines = 2500 [flake8] From 72a6b9eeafdea6c821c69d62f9bccd4ce671d77a Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Mon, 10 Apr 2017 13:20:32 +0300 Subject: [PATCH 41/45] Refactor and test SSH.Algorithm. --- ssh-audit.py | 162 ++++++++++++++++++++++--------------- test/test_ssh_algorithm.py | 157 +++++++++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+), 67 deletions(-) create mode 100644 test/test_ssh_algorithm.py diff --git a/ssh-audit.py b/ssh-audit.py index 45cbd9c..30d8ee1 100755 --- a/ssh-audit.py +++ b/ssh-audit.py @@ -1156,49 +1156,79 @@ def sha256(self): return u'SHA256:{0}'.format(r) class Algorithm(object): + class Timeframe(object): + def __init__(self): + # type: () -> None + self.__storage = {} # type: Dict[str, List[Optional[str]]] + + def __contains__(self, product): + # type: (str) -> bool + return product in self.__storage + + def __getitem__(self, product): + # type: (str) -> Sequence[Optional[str]] + return tuple(self.__storage.get(product, [None]*4)) + + def __str__(self): + # type: () -> str + return self.__storage.__str__() + + def __repr__(self): + # type: () -> str + return self.__str__() + + def get_from(self, product, for_server=True): + # type: (str, bool) -> Optional[str] + return self[product][0 if bool(for_server) else 2] + + def get_till(self, product, for_server=True): + # type: (str, bool) -> Optional[str] + return self[product][1 if bool(for_server) else 3] + + def _update(self, versions, pos): + # type: (Optional[str], int) -> None + ssh_versions = {} # type: Dict[str, str] + for_srv, for_cli = pos < 2, pos > 1 + for v in (versions or '').split(','): + ssh_prod, ssh_ver, is_cli = SSH.Algorithm.get_ssh_version(v) + if (not ssh_ver or + (is_cli and for_srv) or + (not is_cli and for_cli and ssh_prod in ssh_versions)): + continue + ssh_versions[ssh_prod] = ssh_ver + for ssh_product, ssh_version in ssh_versions.items(): + if ssh_product not in self.__storage: + self.__storage[ssh_product] = [None]*4 + prev = self[ssh_product][pos] + if (prev is None or + (prev < ssh_version and pos % 2 == 0) or + (prev > ssh_version and pos % 2 == 1)): + self.__storage[ssh_product][pos] = ssh_version + + def update(self, versions, for_server=None): + # type: (List[Optional[str]], Optional[bool]) -> SSH.Algorithm.Timeframe + for_cli = for_server is None or for_server is False + for_srv = for_server is None or for_server is True + vlen = len(versions) + for i in range(min(3, vlen)): + if for_srv and i < 2: + self._update(versions[i], i) + if for_cli and (i % 2 == 0 or vlen == 2): + self._update(versions[i], 3 - 0**i) + return self + @staticmethod def get_ssh_version(version_desc): - # type: (str) -> Tuple[str, str] + # type: (str) -> Tuple[str, str, bool] + is_client = version_desc.endswith('C') + if is_client: + version_desc = version_desc[:-1] if version_desc.startswith('d'): - return SSH.Product.DropbearSSH, version_desc[1:] + return SSH.Product.DropbearSSH, version_desc[1:], is_client elif version_desc.startswith('l1'): - return SSH.Product.LibSSH, version_desc[2:] + return SSH.Product.LibSSH, version_desc[2:], is_client else: - return SSH.Product.OpenSSH, version_desc - - @classmethod - def get_timeframe(cls, versions, for_server=True, result=None): - # type: (List[Optional[str]], bool, Optional[Dict[str, List[Optional[str]]]]) -> Dict[str, List[Optional[str]]] - result = result or {} - vlen = len(versions) - for i in range(3): - if i > vlen - 1: - if i == 2 and vlen > 1: - cversions = versions[1] - else: - continue - else: - cversions = versions[i] - if cversions is None: - continue - for v in cversions.split(','): - ssh_prefix, ssh_version = cls.get_ssh_version(v) - if not ssh_version: - continue - if ssh_version.endswith('C'): - if for_server: - continue - ssh_version = ssh_version[:-1] - if ssh_prefix not in result: - result[ssh_prefix] = [None, None, None] - prev, push = result[ssh_prefix][i], False - if (prev is None or - (prev < ssh_version and i == 0) or - (prev > ssh_version and i > 0)): - push = True - if push: - result[ssh_prefix][i] = ssh_version - return result + return SSH.Product.OpenSSH, version_desc, is_client @classmethod def get_since_text(cls, versions): @@ -1207,14 +1237,14 @@ def get_since_text(cls, versions): if len(versions) == 0 or versions[0] is None: return None for v in versions[0].split(','): - ssh_prefix, ssh_version = cls.get_ssh_version(v) - if not ssh_version: + ssh_prod, ssh_ver, is_cli = cls.get_ssh_version(v) + if not ssh_ver: continue - if ssh_prefix in [SSH.Product.LibSSH]: + if ssh_prod in [SSH.Product.LibSSH]: continue - if ssh_version.endswith('C'): - ssh_version = '{0} (client only)'.format(ssh_version[:-1]) - tv.append('{0} {1}'.format(ssh_prefix, ssh_version)) + if is_cli: + ssh_ver = '{0} (client only)'.format(ssh_ver) + tv.append('{0} {1}'.format(ssh_prod, ssh_ver)) if len(tv) == 0: return None return 'available since ' + ', '.join(tv).rstrip(', ') @@ -1284,9 +1314,9 @@ def _ml(items): maxlen) return maxlen - def get_ssh_timeframe(self, for_server=True): - # type: (bool) -> Dict[str, List[Optional[str]]] - r = {} # type: Dict[str, List[Optional[str]]] + def get_ssh_timeframe(self, for_server=None): + # type: (Optional[bool]) -> SSH.Algorithm.Timeframe + timeframe = SSH.Algorithm.Timeframe() for alg_pair in self.values: alg_db = alg_pair.db for alg_type, alg_list in alg_pair.items(): @@ -1296,8 +1326,8 @@ def get_ssh_timeframe(self, for_server=True): if alg_desc is None: continue versions = alg_desc[0] - r = SSH.Algorithm.get_timeframe(versions, for_server, r) - return r + timeframe.update(versions, for_server) + return timeframe def get_recommendations(self, software, for_server=True): # type: (Optional[SSH.Software], bool) -> Tuple[Optional[SSH.Software], Dict[int, Dict[str, Dict[str, Dict[str, int]]]]] @@ -1313,7 +1343,7 @@ def get_recommendations(self, software, for_server=True): for product in vproducts: if product not in ssh_timeframe: continue - version = ssh_timeframe[product][0] + version = ssh_timeframe.get_from(product, for_server) if version is not None: software = SSH.Software(None, product, version, None, None) break @@ -1335,15 +1365,13 @@ def get_recommendations(self, software, for_server=True): continue matches = False for v in versions[0].split(','): - ssh_prefix, ssh_version = SSH.Algorithm.get_ssh_version(v) + ssh_prefix, ssh_version, is_cli = SSH.Algorithm.get_ssh_version(v) if not ssh_version: continue if ssh_prefix != software.product: continue - if ssh_version.endswith('C'): - if for_server: - continue - ssh_version = ssh_version[:-1] + if is_cli and for_server: + continue if software.compare_version(ssh_version) < 0: continue matches = True @@ -1842,25 +1870,25 @@ def output_algorithm(alg_db, alg_type, alg_name, alg_max_len=0): def output_compatibility(algs, for_server=True): # type: (SSH.Algorithms, bool) -> None ssh_timeframe = algs.get_ssh_timeframe(for_server) - vp = 1 if for_server else 2 comp_text = [] - for sshd_name in [SSH.Product.OpenSSH, SSH.Product.DropbearSSH]: - if sshd_name not in ssh_timeframe: + for ssh_prod in [SSH.Product.OpenSSH, SSH.Product.DropbearSSH]: + if ssh_prod not in ssh_timeframe: continue - v = ssh_timeframe[sshd_name] - if v[0] is None: + v_from = ssh_timeframe.get_from(ssh_prod, for_server) + v_till = ssh_timeframe.get_till(ssh_prod, for_server) + if v_from is None: continue - if v[vp] is None: - comp_text.append('{0} {1}+'.format(sshd_name, v[0])) - elif v[0] == v[vp]: - comp_text.append('{0} {1}'.format(sshd_name, v[0])) + if v_till is None: + comp_text.append('{0} {1}+'.format(ssh_prod, v_from)) + elif v_from == v_till: + comp_text.append('{0} {1}'.format(ssh_prod, v_from)) else: - software = SSH.Software(None, sshd_name, v[0], None, None) - if software.compare_version(v[vp]) > 0: + software = SSH.Software(None, ssh_prod, v_from, None, None) + if software.compare_version(v_till) > 0: tfmt = '{0} {1}+ (some functionality from {2})' else: tfmt = '{0} {1}-{2}' - comp_text.append(tfmt.format(sshd_name, v[0], v[vp])) + comp_text.append(tfmt.format(ssh_prod, v_from, v_till)) if len(comp_text) > 0: out.good('(gen) compatibility: ' + ', '.join(comp_text)) diff --git a/test/test_ssh_algorithm.py b/test/test_ssh_algorithm.py new file mode 100644 index 0000000..5ba88e5 --- /dev/null +++ b/test/test_ssh_algorithm.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import pytest + + +# pylint: disable=attribute-defined-outside-init +class TestSSHAlgorithm(object): + @pytest.fixture(autouse=True) + def init(self, ssh_audit): + self.ssh = ssh_audit.SSH + + def _tf(self, v, s=None): + return self.ssh.Algorithm.Timeframe().update(v, s) + + def test_get_ssh_version(self): + def ver(v): + return self.ssh.Algorithm.get_ssh_version(v) + + assert ver('7.5') == ('OpenSSH', '7.5', False) + assert ver('7.5C') == ('OpenSSH', '7.5', True) + assert ver('d2016.74') == ('Dropbear SSH', '2016.74', False) + assert ver('l10.7.4') == ('libssh', '0.7.4', False) + assert ver('')[1] == '' + + def test_get_since_text(self): + def gst(v): + return self.ssh.Algorithm.get_since_text(v) + + assert gst(['7.5']) == 'available since OpenSSH 7.5' + assert gst(['7.5C']) == 'available since OpenSSH 7.5 (client only)' + assert gst(['7.5,']) == 'available since OpenSSH 7.5' + assert gst(['d2016.73']) == 'available since Dropbear SSH 2016.73' + assert gst(['7.5,d2016.73']) == 'available since OpenSSH 7.5, Dropbear SSH 2016.73' + assert gst(['l10.7.4']) is None + assert gst([]) is None + + def test_timeframe_creation(self): + # pylint: disable=line-too-long,too-many-statements + def cmp_tf(v, s, r): + assert str(self._tf(v, s)) == str(r) + + cmp_tf(['6.2'], None, {'OpenSSH': ['6.2', None, '6.2', None]}) + cmp_tf(['6.2'], True, {'OpenSSH': ['6.2', None, None, None]}) + cmp_tf(['6.2'], False, {'OpenSSH': [None, None, '6.2', None]}) + cmp_tf(['6.2C'], None, {'OpenSSH': [None, None, '6.2', None]}) + cmp_tf(['6.2C'], True, {}) + cmp_tf(['6.2C'], False, {'OpenSSH': [None, None, '6.2', None]}) + cmp_tf(['6.1,6.2C'], None, {'OpenSSH': ['6.1', None, '6.2', None]}) + cmp_tf(['6.1,6.2C'], True, {'OpenSSH': ['6.1', None, None, None]}) + cmp_tf(['6.1,6.2C'], False, {'OpenSSH': [None, None, '6.2', None]}) + cmp_tf(['6.2C,6.1'], None, {'OpenSSH': ['6.1', None, '6.2', None]}) + cmp_tf(['6.2C,6.1'], True, {'OpenSSH': ['6.1', None, None, None]}) + cmp_tf(['6.2C,6.1'], False, {'OpenSSH': [None, None, '6.2', None]}) + cmp_tf(['6.3,6.2C'], None, {'OpenSSH': ['6.3', None, '6.2', None]}) + cmp_tf(['6.3,6.2C'], True, {'OpenSSH': ['6.3', None, None, None]}) + cmp_tf(['6.3,6.2C'], False, {'OpenSSH': [None, None, '6.2', None]}) + cmp_tf(['6.2C,6.3'], None, {'OpenSSH': ['6.3', None, '6.2', None]}) + cmp_tf(['6.2C,6.3'], True, {'OpenSSH': ['6.3', None, None, None]}) + cmp_tf(['6.2C,6.3'], False, {'OpenSSH': [None, None, '6.2', None]}) + + cmp_tf(['6.2', '6.6'], None, {'OpenSSH': ['6.2', '6.6', '6.2', '6.6']}) + cmp_tf(['6.2', '6.6'], True, {'OpenSSH': ['6.2', '6.6', None, None]}) + cmp_tf(['6.2', '6.6'], False, {'OpenSSH': [None, None, '6.2', '6.6']}) + cmp_tf(['6.2C', '6.6'], None, {'OpenSSH': [None, '6.6', '6.2', '6.6']}) + cmp_tf(['6.2C', '6.6'], True, {'OpenSSH': [None, '6.6', None, None]}) + cmp_tf(['6.2C', '6.6'], False, {'OpenSSH': [None, None, '6.2', '6.6']}) + cmp_tf(['6.1,6.2C', '6.6'], None, {'OpenSSH': ['6.1', '6.6', '6.2', '6.6']}) + cmp_tf(['6.1,6.2C', '6.6'], True, {'OpenSSH': ['6.1', '6.6', None, None]}) + cmp_tf(['6.1,6.2C', '6.6'], False, {'OpenSSH': [None, None, '6.2', '6.6']}) + cmp_tf(['6.2C,6.1', '6.6'], None, {'OpenSSH': ['6.1', '6.6', '6.2', '6.6']}) + cmp_tf(['6.2C,6.1', '6.6'], True, {'OpenSSH': ['6.1', '6.6', None, None]}) + cmp_tf(['6.2C,6.1', '6.6'], False, {'OpenSSH': [None, None, '6.2', '6.6']}) + cmp_tf(['6.3,6.2C', '6.6'], None, {'OpenSSH': ['6.3', '6.6', '6.2', '6.6']}) + cmp_tf(['6.3,6.2C', '6.6'], True, {'OpenSSH': ['6.3', '6.6', None, None]}) + cmp_tf(['6.3,6.2C', '6.6'], False, {'OpenSSH': [None, None, '6.2', '6.6']}) + cmp_tf(['6.2C,6.3', '6.6'], None, {'OpenSSH': ['6.3', '6.6', '6.2', '6.6']}) + cmp_tf(['6.2C,6.3', '6.6'], True, {'OpenSSH': ['6.3', '6.6', None, None]}) + cmp_tf(['6.2C,6.3', '6.6'], False, {'OpenSSH': [None, None, '6.2', '6.6']}) + + cmp_tf(['6.2', '6.6', None], None, {'OpenSSH': ['6.2', '6.6', '6.2', None]}) + cmp_tf(['6.2', '6.6', None], True, {'OpenSSH': ['6.2', '6.6', None, None]}) + cmp_tf(['6.2', '6.6', None], False, {'OpenSSH': [None, None, '6.2', None]}) + cmp_tf(['6.2C', '6.6', None], None, {'OpenSSH': [None, '6.6', '6.2', None]}) + cmp_tf(['6.2C', '6.6', None], True, {'OpenSSH': [None, '6.6', None, None]}) + cmp_tf(['6.2C', '6.6', None], False, {'OpenSSH': [None, None, '6.2', None]}) + cmp_tf(['6.1,6.2C', '6.6', None], None, {'OpenSSH': ['6.1', '6.6', '6.2', None]}) + cmp_tf(['6.1,6.2C', '6.6', None], True, {'OpenSSH': ['6.1', '6.6', None, None]}) + cmp_tf(['6.1,6.2C', '6.6', None], False, {'OpenSSH': [None, None, '6.2', None]}) + cmp_tf(['6.2C,6.1', '6.6', None], None, {'OpenSSH': ['6.1', '6.6', '6.2', None]}) + cmp_tf(['6.2C,6.1', '6.6', None], True, {'OpenSSH': ['6.1', '6.6', None, None]}) + cmp_tf(['6.2C,6.1', '6.6', None], False, {'OpenSSH': [None, None, '6.2', None]}) + cmp_tf(['6.2,6.3C', '6.6', None], None, {'OpenSSH': ['6.2', '6.6', '6.3', None]}) + cmp_tf(['6.2,6.3C', '6.6', None], True, {'OpenSSH': ['6.2', '6.6', None, None]}) + cmp_tf(['6.2,6.3C', '6.6', None], False, {'OpenSSH': [None, None, '6.3', None]}) + cmp_tf(['6.3C,6.2', '6.6', None], None, {'OpenSSH': ['6.2', '6.6', '6.3', None]}) + cmp_tf(['6.3C,6.2', '6.6', None], True, {'OpenSSH': ['6.2', '6.6', None, None]}) + cmp_tf(['6.3C,6.2', '6.6', None], False, {'OpenSSH': [None, None, '6.3', None]}) + + cmp_tf(['6.2', '6.6', '7.1'], None, {'OpenSSH': ['6.2', '6.6', '6.2', '7.1']}) + cmp_tf(['6.2', '6.6', '7.1'], True, {'OpenSSH': ['6.2', '6.6', None, None]}) + cmp_tf(['6.2', '6.6', '7.1'], False, {'OpenSSH': [None, None, '6.2', '7.1']}) + cmp_tf(['6.1,6.2C', '6.6', '7.1'], None, {'OpenSSH': ['6.1', '6.6', '6.2', '7.1']}) + cmp_tf(['6.1,6.2C', '6.6', '7.1'], True, {'OpenSSH': ['6.1', '6.6', None, None]}) + cmp_tf(['6.1,6.2C', '6.6', '7.1'], False, {'OpenSSH': [None, None, '6.2', '7.1']}) + cmp_tf(['6.2C,6.1', '6.6', '7.1'], None, {'OpenSSH': ['6.1', '6.6', '6.2', '7.1']}) + cmp_tf(['6.2C,6.1', '6.6', '7.1'], True, {'OpenSSH': ['6.1', '6.6', None, None]}) + cmp_tf(['6.2C,6.1', '6.6', '7.1'], False, {'OpenSSH': [None, None, '6.2', '7.1']}) + cmp_tf(['6.2,6.3C', '6.6', '7.1'], None, {'OpenSSH': ['6.2', '6.6', '6.3', '7.1']}) + cmp_tf(['6.2,6.3C', '6.6', '7.1'], True, {'OpenSSH': ['6.2', '6.6', None, None]}) + cmp_tf(['6.2,6.3C', '6.6', '7.1'], False, {'OpenSSH': [None, None, '6.3', '7.1']}) + cmp_tf(['6.3C,6.2', '6.6', '7.1'], None, {'OpenSSH': ['6.2', '6.6', '6.3', '7.1']}) + cmp_tf(['6.3C,6.2', '6.6', '7.1'], True, {'OpenSSH': ['6.2', '6.6', None, None]}) + cmp_tf(['6.3C,6.2', '6.6', '7.1'], False, {'OpenSSH': [None, None, '6.3', '7.1']}) + + tf1 = self._tf(['6.1,d2016.72,6.2C', '6.6,d2016.73', '7.1,d2016.74']) + tf2 = self._tf(['d2016.72,6.2C,6.1', 'd2016.73,6.6', 'd2016.74,7.1']) + tf3 = self._tf(['d2016.72,6.2C,6.1', '6.6,d2016.73', '7.1,d2016.74']) + # check without caring for output order + ov = "'OpenSSH': ['6.1', '6.6', '6.2', '7.1']" + dv = "'Dropbear SSH': ['2016.72', '2016.73', '2016.72', '2016.74']" + assert len(str(tf1)) == len(str(tf2)) == len(str(tf3)) + assert ov in str(tf1) and dv in str(tf1) + assert ov in str(tf2) and dv in str(tf3) + assert ov in str(tf2) and dv in str(tf3) + + def test_timeframe_object(self): + tf = self._tf(['6.1,6.2C', '6.6', '7.1']) + assert 'OpenSSH' in tf + assert 'Dropbear SSH' not in tf + assert 'libssh' not in tf + assert 'unknown' not in tf + assert tf['OpenSSH'] == ('6.1', '6.6', '6.2', '7.1') + assert tf['Dropbear SSH'] == (None, None, None, None) + assert tf['libssh'] == (None, None, None, None) + assert tf['unknown'] == (None, None, None, None) + assert tf.get_from('OpenSSH', True) == '6.1' + assert tf.get_till('OpenSSH', True) == '6.6' + assert tf.get_from('OpenSSH', False) == '6.2' + assert tf.get_till('OpenSSH', False) == '7.1' + + tf = self._tf(['6.1,d2016.72,6.2C', '6.6,d2016.73', '7.1,d2016.74']) + assert 'OpenSSH' in tf + assert 'Dropbear SSH' in tf + assert 'libssh' not in tf + assert 'unknown' not in tf + assert tf['OpenSSH'] == ('6.1', '6.6', '6.2', '7.1') + assert tf['Dropbear SSH'] == ('2016.72', '2016.73', '2016.72', '2016.74') + assert tf['libssh'] == (None, None, None, None) + assert tf['unknown'] == (None, None, None, None) + assert tf.get_from('OpenSSH', True) == '6.1' + assert tf.get_till('OpenSSH', True) == '6.6' + assert tf.get_from('OpenSSH', False) == '6.2' + assert tf.get_till('OpenSSH', False) == '7.1' + assert tf.get_from('Dropbear SSH', True) == '2016.72' + assert tf.get_till('Dropbear SSH', True) == '2016.73' + assert tf.get_from('Dropbear SSH', False) == '2016.72' + assert tf.get_till('Dropbear SSH', False) == '2016.74' From 1d1f842bed78b23a9399663dd215dd66d17b3e08 Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Mon, 10 Apr 2017 19:11:12 +0300 Subject: [PATCH 42/45] Refactor output level/colors, fix python:S1845. --- ssh-audit.py | 34 +++++++++++++------------- test/test_auditconf.py | 22 ++++++++--------- test/test_output.py | 54 +++++++++++++++++++++--------------------- 3 files changed, 55 insertions(+), 55 deletions(-) diff --git a/ssh-audit.py b/ssh-audit.py index 30d8ee1..8cb5b06 100755 --- a/ssh-audit.py +++ b/ssh-audit.py @@ -83,7 +83,7 @@ def __init__(self, host=None, port=22): self.batch = False self.colors = True self.verbose = False - self.minlevel = 'info' + self.level = 'info' self.ipvo = () # type: Sequence[int] self.ipv4 = False self.ipv6 = False @@ -118,7 +118,7 @@ def __setattr__(self, name, value): if port < 1 or port > 65535: raise ValueError('invalid port: {0}'.format(value)) value = port - elif name in ['minlevel']: + elif name in ['level']: if value not in ('info', 'warn', 'fail'): raise ValueError('invalid level: {0}'.format(value)) valid = True @@ -164,7 +164,7 @@ def from_cmdline(cls, args, usage_cb): elif o in ('-l', '--level'): if a not in ('info', 'warn', 'fail'): usage_cb('level {0} is not valid'.format(a)) - aconf.minlevel = a + aconf.level = a if len(args) == 0: usage_cb() if oport is not None: @@ -189,30 +189,30 @@ def from_cmdline(cls, args, usage_cb): class Output(object): - LEVELS = ['info', 'warn', 'fail'] + LEVELS = ('info', 'warn', 'fail') # type: Sequence[str] COLORS = {'head': 36, 'good': 32, 'warn': 33, 'fail': 31} def __init__(self): # type: () -> None self.batch = False - self.colors = True self.verbose = False - self.__minlevel = 0 + self.use_colors = True + self.__level = 0 self.__colsupport = 'colorama' in sys.modules or os.name == 'posix' @property - def minlevel(self): + def level(self): # type: () -> str - if self.__minlevel < len(self.LEVELS): - return self.LEVELS[self.__minlevel] + if self.__level < len(self.LEVELS): + return self.LEVELS[self.__level] return 'unknown' - @minlevel.setter - def minlevel(self, name): + @level.setter + def level(self, name): # type: (str) -> None - self.__minlevel = self.getlevel(name) + self.__level = self.get_level(name) - def getlevel(self, name): + def get_level(self, name): # type: (str) -> int cname = 'info' if name == 'good' else name if cname not in self.LEVELS: @@ -238,9 +238,9 @@ def __getattr__(self, name): # type: (str) -> Callable[[text_type], None] if name == 'head' and self.batch: return lambda x: None - if not self.getlevel(name) >= self.__minlevel: + if not self.get_level(name) >= self.__level: return lambda x: None - if self.colors and self.colors_supported and name in self.COLORS: + if self.use_colors and self.colors_supported and name in self.COLORS: color = '\033[0;{0}m'.format(self.COLORS[name]) return self._colorized(color) else: @@ -2160,9 +2160,9 @@ def parse_int(v): def audit(aconf, sshv=None): # type: (AuditConf, Optional[int]) -> None out.batch = aconf.batch - out.colors = aconf.colors out.verbose = aconf.verbose - out.minlevel = aconf.minlevel + out.level = aconf.level + out.use_colors = aconf.colors s = SSH.Socket(aconf.host, aconf.port) s.connect(aconf.ipvo) if sshv is None: diff --git a/test/test_auditconf.py b/test/test_auditconf.py index 3472c42..259a881 100644 --- a/test/test_auditconf.py +++ b/test/test_auditconf.py @@ -10,8 +10,8 @@ def init(self, ssh_audit): self.AuditConf = ssh_audit.AuditConf self.usage = ssh_audit.usage - @classmethod - def _test_conf(cls, conf, **kwargs): + @staticmethod + def _test_conf(conf, **kwargs): options = { 'host': None, 'port': 22, @@ -20,7 +20,7 @@ def _test_conf(cls, conf, **kwargs): 'batch': False, 'colors': True, 'verbose': False, - 'minlevel': 'info', + 'level': 'info', 'ipv4': True, 'ipv6': True, 'ipvo': () @@ -34,7 +34,7 @@ def _test_conf(cls, conf, **kwargs): assert conf.batch is options['batch'] assert conf.colors is options['colors'] assert conf.verbose is options['verbose'] - assert conf.minlevel == options['minlevel'] + assert conf.level == options['level'] assert conf.ipv4 == options['ipv4'] assert conf.ipv6 == options['ipv6'] assert conf.ipvo == options['ipvo'] @@ -115,14 +115,14 @@ def test_audit_conf_ipvo(self): conf.ipvo = (4, 4, 4, 6, 6) assert conf.ipvo == (4, 6) - def test_audit_conf_minlevel(self): + def test_audit_conf_level(self): conf = self.AuditConf() for level in ['info', 'warn', 'fail']: - conf.minlevel = level - assert conf.minlevel == level + conf.level = level + assert conf.level == level for level in ['head', 'good', 'unknown', None]: with pytest.raises(ValueError) as excinfo: - conf.minlevel = level + conf.level = level excinfo.match(r'.*invalid level.*') def test_audit_conf_cmdline(self): @@ -183,10 +183,10 @@ def test_audit_conf_cmdline(self): conf = c('-v localhost') self._test_conf(conf, host='localhost', verbose=True) conf = c('-l info localhost') - self._test_conf(conf, host='localhost', minlevel='info') + self._test_conf(conf, host='localhost', level='info') conf = c('-l warn localhost') - self._test_conf(conf, host='localhost', minlevel='warn') + self._test_conf(conf, host='localhost', level='warn') conf = c('-l fail localhost') - self._test_conf(conf, host='localhost', minlevel='fail') + self._test_conf(conf, host='localhost', level='fail') with pytest.raises(SystemExit): conf = c('-l something localhost') diff --git a/test/test_output.py b/test/test_output.py index 74b2c19..3ac6f06 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -41,13 +41,13 @@ def test_output_defaults(self): out = self.Output() # default: on assert out.batch is False - assert out.colors is True - assert out.minlevel == 'info' + assert out.use_colors is True + assert out.level == 'info' def test_output_colors(self, output_spy): out = self.Output() # test without colors - out.colors = False + out.use_colors = False output_spy.begin() out.info('info color') assert output_spy.flush() == [u'info color'] @@ -66,7 +66,7 @@ def test_output_colors(self, output_spy): if not out.colors_supported: return # test with colors - out.colors = True + out.use_colors = True output_spy.begin() out.info('info color') assert output_spy.flush() == [u'info color'] @@ -93,29 +93,29 @@ def test_output_sep(self, output_spy): def test_output_levels(self): out = self.Output() - assert out.getlevel('info') == 0 - assert out.getlevel('good') == 0 - assert out.getlevel('warn') == 1 - assert out.getlevel('fail') == 2 - assert out.getlevel('unknown') > 2 + assert out.get_level('info') == 0 + assert out.get_level('good') == 0 + assert out.get_level('warn') == 1 + assert out.get_level('fail') == 2 + assert out.get_level('unknown') > 2 - def test_output_minlevel_property(self): + def test_output_level_property(self): out = self.Output() - out.minlevel = 'info' - assert out.minlevel == 'info' - out.minlevel = 'good' - assert out.minlevel == 'info' - out.minlevel = 'warn' - assert out.minlevel == 'warn' - out.minlevel = 'fail' - assert out.minlevel == 'fail' - out.minlevel = 'invalid level' - assert out.minlevel == 'unknown' + out.level = 'info' + assert out.level == 'info' + out.level = 'good' + assert out.level == 'info' + out.level = 'warn' + assert out.level == 'warn' + out.level = 'fail' + assert out.level == 'fail' + out.level = 'invalid level' + assert out.level == 'unknown' - def test_output_minlevel(self, output_spy): + def test_output_level(self, output_spy): out = self.Output() # visible: all - out.minlevel = 'info' + out.level = 'info' output_spy.begin() out.info('info color') out.head('head color') @@ -124,7 +124,7 @@ def test_output_minlevel(self, output_spy): out.fail('fail color') assert len(output_spy.flush()) == 5 # visible: head, warn, fail - out.minlevel = 'warn' + out.level = 'warn' output_spy.begin() out.info('info color') out.head('head color') @@ -133,7 +133,7 @@ def test_output_minlevel(self, output_spy): out.fail('fail color') assert len(output_spy.flush()) == 3 # visible: head, fail - out.minlevel = 'fail' + out.level = 'fail' output_spy.begin() out.info('info color') out.head('head color') @@ -142,7 +142,7 @@ def test_output_minlevel(self, output_spy): out.fail('fail color') assert len(output_spy.flush()) == 2 # visible: head - out.minlevel = 'invalid level' + out.level = 'invalid level' output_spy.begin() out.info('info color') out.head('head color') @@ -155,7 +155,7 @@ def test_output_batch(self, output_spy): out = self.Output() # visible: all output_spy.begin() - out.minlevel = 'info' + out.level = 'info' out.batch = False out.info('info color') out.head('head color') @@ -165,7 +165,7 @@ def test_output_batch(self, output_spy): assert len(output_spy.flush()) == 5 # visible: all except head output_spy.begin() - out.minlevel = 'info' + out.level = 'info' out.batch = True out.info('info color') out.head('head color') From 9c463b4e061987f2de593f4ed68a523449527a1e Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Mon, 10 Apr 2017 19:32:40 +0300 Subject: [PATCH 43/45] Fix lint tox environment. --- tox.ini | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tox.ini b/tox.ini index 0655e65..7f61a11 100644 --- a/tox.ini +++ b/tox.ini @@ -13,9 +13,9 @@ deps = test,cov: {[testenv:cov]deps} test,py{33,34,35,36,37}-{type,mypy}: colorama==0.3.7 py{33,34,35,36,37}-{type,mypy}: {[testenv:mypy]deps} - py{27,py,py3,33,34,35,36,37}-{lint,pylint}: {[testenv:pylint]deps} - py{27,py,py3,33,34,35,36,37}-{lint,flake8}: {[testenv:flake8]deps} - lint,vulture: {[testenv:vulture]deps} + py{27,py,py3,33,34,35,36,37}-{lint,pylint},lint: {[testenv:pylint]deps} + py{27,py,py3,33,34,35,36,37}-{lint,flake8},lint: {[testenv:flake8]deps} + py{27,py,py3,33,34,35,36,37}-{lint,vulture},lint: {[testenv:vulture]deps} setenv = SSHAUDIT = {toxinidir}/ssh-audit.py test: COVERAGE_FILE = {toxinidir}/.coverage.{envname} @@ -27,9 +27,9 @@ commands = test: coverage report --show-missing test: coverage html -d {toxinidir}/reports/html/coverage.{envname} py{33,34,35,36,37}-{type,mypy}: {[testenv:mypy]commands} - py{27,py,py3,33,34,35,36,37}-{lint,pylint}: {[testenv:pylint]commands} - py{27,py,py3,33,34,35,36,37}-{lint,flake8}: {[testenv:flake8]commands} - lint,vulture: {[testenv:vulture]commands} + py{27,py,py3,33,34,35,36,37}-{lint,pylint},lint: {[testenv:pylint]commands} + py{27,py,py3,33,34,35,36,37}-{lint,flake8},lint: {[testenv:flake8]commands} + py{27,py,py3,33,34,35,36,37}-{lint,vulture},lint: {[testenv:vulture]commands} ignore_outcome = type: true lint: true From 96d442ec6291f9ba4b21514e05648bbd6e7a05c7 Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Tue, 11 Apr 2017 13:32:38 +0300 Subject: [PATCH 44/45] Test Timeframe repr(). --- test/test_ssh_algorithm.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/test/test_ssh_algorithm.py b/test/test_ssh_algorithm.py index 5ba88e5..5e03529 100644 --- a/test/test_ssh_algorithm.py +++ b/test/test_ssh_algorithm.py @@ -119,9 +119,10 @@ def cmp_tf(v, s, r): ov = "'OpenSSH': ['6.1', '6.6', '6.2', '7.1']" dv = "'Dropbear SSH': ['2016.72', '2016.73', '2016.72', '2016.74']" assert len(str(tf1)) == len(str(tf2)) == len(str(tf3)) - assert ov in str(tf1) and dv in str(tf1) - assert ov in str(tf2) and dv in str(tf3) - assert ov in str(tf2) and dv in str(tf3) + assert ov in str(tf1) and ov in str(tf2) and ov in str(tf3) + assert dv in str(tf1) and dv in str(tf2) and dv in str(tf3) + assert ov in repr(tf1) and ov in repr(tf2) and ov in repr(tf3) + assert dv in repr(tf1) and dv in repr(tf2) and dv in repr(tf3) def test_timeframe_object(self): tf = self._tf(['6.1,6.2C', '6.6', '7.1']) @@ -155,3 +156,9 @@ def test_timeframe_object(self): assert tf.get_till('Dropbear SSH', True) == '2016.73' assert tf.get_from('Dropbear SSH', False) == '2016.72' assert tf.get_till('Dropbear SSH', False) == '2016.74' + ov = "'OpenSSH': ['6.1', '6.6', '6.2', '7.1']" + dv = "'Dropbear SSH': ['2016.72', '2016.73', '2016.72', '2016.74']" + assert ov in str(tf) + assert dv in str(tf) + assert ov in repr(tf) + assert dv in repr(tf) From d8eb46d7669aa92ad08ad4e714cdab88ff14496f Mon Sep 17 00:00:00 2001 From: Andris Raugulis Date: Fri, 5 May 2017 14:12:45 +0300 Subject: [PATCH 45/45] Correct IPv6 parsing in command-line. Fixes #26. --- ssh-audit.py | 15 +++++++++------ test/test_auditconf.py | 8 ++++++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/ssh-audit.py b/ssh-audit.py index 8cb5b06..461b953 100755 --- a/ssh-audit.py +++ b/ssh-audit.py @@ -169,14 +169,17 @@ def from_cmdline(cls, args, usage_cb): usage_cb() if oport is not None: host = args[0] - port = utils.parse_int(oport) else: - s = args[0].split(':') - host = s[0].strip() - if len(s) == 2: - oport, port = s[1], utils.parse_int(s[1]) + mx = re.match(r'^\[([^\]]+)\](?::(.*))?$', args[0]) + if bool(mx): + host, oport = mx.group(1), mx.group(2) else: - oport, port = '22', 22 + s = args[0].split(':') + if len(s) > 2: + host, oport = args[0], '22' + else: + host, oport = s[0], s[1] if len(s) > 1 else '22' + port = utils.parse_int(oport) if not host: usage_cb('host is empty') if port <= 0 or port > 65535: diff --git a/test/test_auditconf.py b/test/test_auditconf.py index 259a881..a901299 100644 --- a/test/test_auditconf.py +++ b/test/test_auditconf.py @@ -148,6 +148,14 @@ def test_audit_conf_cmdline(self): self._test_conf(conf, host='localhost', port=2222) conf = c('-p 2222 localhost') self._test_conf(conf, host='localhost', port=2222) + conf = c('2001:4860:4860::8888') + self._test_conf(conf, host='2001:4860:4860::8888') + conf = c('[2001:4860:4860::8888]:22') + self._test_conf(conf, host='2001:4860:4860::8888') + conf = c('[2001:4860:4860::8888]:2222') + self._test_conf(conf, host='2001:4860:4860::8888', port=2222) + conf = c('-p 2222 2001:4860:4860::8888') + self._test_conf(conf, host='2001:4860:4860::8888', port=2222) with pytest.raises(SystemExit): conf = c('localhost:') with pytest.raises(SystemExit):