├── tests
├── __init__.py
├── test_config.py
├── test_generate_config.py
├── test_kernel.py
├── test_nginx.py
├── test_sec.py
├── test_neutron.py
├── test_manila.py
├── test_horizon.py
└── test_cinder.py
├── reconbf
├── __init__.py
├── lib
│ ├── __init__.py
│ ├── logger.py
│ ├── constants.py
│ └── config.py
├── modules
│ ├── __init__.py
│ ├── test_secureboot.py
│ ├── test_hardware.py
│ ├── test_package_support.py
│ ├── test_access.py
│ ├── test_signing.py
│ ├── test_dns.py
│ ├── test_firewall.py
│ ├── test_mem.py
│ ├── test_mysql.py
│ ├── test_mounts.py
│ ├── test_neutron.py
│ ├── test_mac.py
│ ├── test_nova.py
│ ├── test_keystone.py
│ ├── test_haproxy.py
│ ├── test_upgrades.py
│ ├── test_manila.py
│ ├── test_sec.py
│ ├── test_horizon.py
│ ├── test_php.py
│ ├── test_stunnel.py
│ ├── test_cinder.py
│ └── test_users.py
├── templates
│ └── results_template.html
└── __main__.py
├── scripts
├── test_script
└── hos_v1
├── config
├── signing.cfg
├── default.cfg
├── docker.cfg
├── rbf.cfg
└── hos.cfg
├── test-requirements.txt
├── setup.py
├── .travis.yml
├── setup.cfg
├── tox.ini
├── .gitignore
└── README.rst
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/reconbf/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/reconbf/lib/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/reconbf/modules/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/scripts/test_script:
--------------------------------------------------------------------------------
1 | test_sec.test_sysctl_values
2 | test_mac.test_apparmor
3 |
--------------------------------------------------------------------------------
/config/signing.cfg:
--------------------------------------------------------------------------------
1 | {
2 | "modules": {
3 | "test_secureboot": {}
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/test-requirements.txt:
--------------------------------------------------------------------------------
1 | flake8==2.1.0
2 | pep8==1.5.6
3 | pyflakes!=1.2.0,!=1.2.1,!=1.2.2
4 | mock
5 | coverage
6 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | from setuptools import setup
4 |
5 | setup(
6 | setup_requires=['pbr'],
7 | pbr=True,
8 | )
9 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: python
2 | sudo: false
3 | python:
4 | - "3.5"
5 | - "2.7"
6 | install:
7 | - pip install tox-travis
8 | - pip install coveralls
9 | script: tox
10 | after_success:
11 | coveralls
12 |
--------------------------------------------------------------------------------
/scripts/hos_v1:
--------------------------------------------------------------------------------
1 | test_binaries.test_system_critical
2 | test_dns.test_default_dns_search
3 | test_dns.test_dns_name
4 | test_file_controls.test_perms_and_ownership
5 | test_file_controls.test_perms_files_in_dir
6 | test_kernel.test_pax
7 | test_kernel.test_proc_map_access
8 | test_kernel.test_ptrace_scope
9 | test_mac.test_apparmor
10 | test_mem.test_NX
11 | test_mem.test_devmem
12 | test_sec.test_shellshock
13 | test_sec.test_sysctl_values
14 | test_services.test_running_services
15 | test_services.test_service_config
16 | test_users.test_accounts_nopassword
17 | test_users.test_list_sudoers
18 | test_users.test_unique_group
19 | test_users.test_unique_user
20 |
--------------------------------------------------------------------------------
/tests/test_config.py:
--------------------------------------------------------------------------------
1 | from reconbf.lib import config
2 |
3 | import io
4 | import unittest
5 |
6 |
7 | class Config(unittest.TestCase):
8 | def test_load_empty(self):
9 | config_file = io.StringIO(u"{}")
10 | cfg = config.Config(config_file)
11 | self.assertEqual(cfg.get_configured_tests(), {})
12 |
13 | def test_list_tests(self):
14 | config_file = io.StringIO(u"""{
15 | "modules":
16 | {
17 | "test_kernel": {
18 | "test_pax": null
19 | }
20 | }
21 | }""")
22 | cfg = config.Config(config_file)
23 | self.assertEqual(cfg.get_configured_tests(),
24 | {"test_kernel": ["test_pax"]})
25 |
--------------------------------------------------------------------------------
/config/default.cfg:
--------------------------------------------------------------------------------
1 | {
2 | "modules": {
3 | "test_binaries": {
4 | "test_setuid_files": null,
5 | "test_listening_files": null,
6 | "test_system_critical": null
7 | },
8 | "test_mac": {},
9 | "test_mem": {},
10 | "test_users": {},
11 | "test_file_controls": {
12 | "test_perms_and_ownership": null,
13 | "test_perms_files_in_dir": null
14 | },
15 | "test_services": {
16 | "test_running_services": null,
17 | "test_service_config": null
18 | },
19 | "test_docker": {},
20 | "test_sec": {
21 | "test_sysctl_values": null,
22 | "test_shellshock": null
23 | },
24 | "test_kernel": {},
25 | "test_dns": {}
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [metadata]
2 | name = reconbf
3 | author = HPE security
4 | summary = System configuration verifier
5 | description-file = README.rst
6 | classifier =
7 | Development Status :: 4 - Beta
8 | Environment :: Console
9 | Intended Audience :: Information Technology
10 | Intended Audience :: System Administrators
11 | License :: OSI Approved :: Apache Software License
12 | Operating System :: OS Independent
13 | Programming Language :: Python
14 | Programming Language :: Python :: 2.7
15 | Programming Language :: Python :: 3.4
16 | Programming Language :: Python :: 3.5
17 | Topic :: Security
18 | Topic :: Utilities
19 | keywords =
20 | security
21 | hardening
22 | system
23 | [files]
24 | packages =
25 | reconbf
26 | data_files =
27 | etc/reconbf = config/*
28 | [entry_points]
29 | console_scripts =
30 | reconbf = reconbf.__main__:main
31 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | minversion = 1.6
3 | envlist = pep8
4 | skipsdist = True
5 |
6 | [testenv]
7 | install_command = pip install -U {opts} {packages}
8 | setenv =
9 | VIRTUAL_ENV={envdir}
10 | deps = -r{toxinidir}/test-requirements.txt
11 | commands =
12 | coverage erase
13 | coverage run -p setup.py test -s tests {posargs}
14 | coverage combine
15 | coverage report -m --omit ".tox/*"
16 |
17 | [testenv:pep8]
18 | commands = flake8 {posargs} .
19 |
20 | [flake8]
21 | # E123, E125 skipped as they are invalid PEP-8.
22 | # E241 space after : is fine
23 | # E731 lambda is fine
24 | # H405 docstring summary line is overkill
25 | # H104 file contains nothing but comments
26 | # H302 import only modules
27 |
28 | show-source = True
29 | ignore = E123,E125,E241,E731,H405,H104,H302
30 | builtins = _
31 | exclude=.venv*,.git,.tox,.idea,config,scripts,templates,.eggs
32 |
33 | [tox:travis]
34 | 2.7 = py27
35 | 3.5 = py35, pep8
36 |
--------------------------------------------------------------------------------
/tests/test_generate_config.py:
--------------------------------------------------------------------------------
1 | from reconbf import __main__
2 |
3 | import io
4 | import json
5 | import unittest
6 |
7 |
8 | class ConfigGeneration(unittest.TestCase):
9 | def test_default(self):
10 | output = io.StringIO()
11 | __main__._write_generated_config(output, "default")
12 | self.assertTrue(len(output.getvalue()) > 0)
13 |
14 | def test_inline(self):
15 | output = io.StringIO()
16 | __main__._write_generated_config(output, "inline")
17 | self.assertTrue(len(output.getvalue()) > 0)
18 |
19 |
20 | class DefaultConfig(unittest.TestCase):
21 | def test_all_entries(self):
22 | """Are all tests contained in the default config"""
23 | with open("config/rbf.cfg", "r") as f:
24 | default_config = json.load(f)
25 | generated = __main__._generate_config('default')
26 |
27 | self.assertEqual(default_config['modules'], generated['modules'])
28 |
--------------------------------------------------------------------------------
/reconbf/lib/logger.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Hewlett Packard Enterprise Development LP
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | import logging
16 | from . import constants
17 |
18 | # global logger
19 | logger = logging.getLogger(constants.LOG_NAME)
20 | formatter = logging.Formatter(fmt=constants.LOG_FMT)
21 | handler = logging.StreamHandler()
22 | handler.setFormatter(formatter)
23 | logger.addHandler(handler)
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 |
5 | # C extensions
6 | *.so
7 |
8 | # Distribution / packaging
9 | .Python
10 | env/
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | parts/
18 | sdist/
19 | var/
20 | *.egg-info/
21 | .installed.cfg
22 | *.egg
23 |
24 | # PyInstaller
25 | # Usually these files are written by a python script from a template
26 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
27 | *.manifest
28 | *.spec
29 |
30 | # Installer logs
31 | pip-log.txt
32 | pip-delete-this-directory.txt
33 |
34 | # Unit test / coverage reports
35 | htmlcov/
36 | .tox/
37 | .coverage
38 | .coverage.*
39 | .cache
40 | nosetests.xml
41 | coverage.xml
42 | *,cover
43 |
44 | # Translations
45 | *.mo
46 | *.pot
47 |
48 | # Django stuff:
49 | *.log
50 |
51 | # Sphinx documentation
52 | docs/_build/
53 |
54 | # PyBuilder
55 | target/
56 |
57 | # vim swap files
58 | *.sw?
59 |
60 | # default result
61 | result.out
62 |
--------------------------------------------------------------------------------
/tests/test_kernel.py:
--------------------------------------------------------------------------------
1 | from reconbf.modules import test_kernel
2 | from reconbf.lib.result import Result
3 | from reconbf.lib import utils
4 |
5 | import unittest
6 | from mock import patch
7 |
8 |
9 | class PtraceScope(unittest.TestCase):
10 | def test_no_yama(self):
11 | with patch.object(utils, 'kconfig_option', return_value=None):
12 | res = test_kernel.test_ptrace_scope()
13 | self.assertEqual(res.result, Result.FAIL)
14 |
15 | def test_level_0(self):
16 | with patch.object(utils, 'kconfig_option', return_value='y'):
17 | with patch.object(utils, 'get_sysctl_value', return_value='0'):
18 | res = test_kernel.test_ptrace_scope()
19 | self.assertEqual(res.result, Result.FAIL)
20 |
21 | def test_level_1(self):
22 | with patch.object(utils, 'kconfig_option', return_value='y'):
23 | with patch.object(utils, 'get_sysctl_value', return_value='1'):
24 | res = test_kernel.test_ptrace_scope()
25 | self.assertEqual(res.result, Result.PASS)
26 |
--------------------------------------------------------------------------------
/reconbf/lib/constants.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Hewlett Packard Enterprise Development LP
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | """
16 | Sensible defaults are listed here, so that RBF can function even in the case
17 | that they aren't specified in the config file.
18 | """
19 |
20 | LOG_NAME = 'root'
21 | LOG_FMT = '%(asctime)s %(levelname)7s: %(message)s - (%(filename)s:%(lineno)d)'
22 | MAX_LINE_LENGTH = 200
23 | SYSCTL_PATH = '/proc/sys'
24 | TC_END = '\033[0;m'
25 | TC_FAIL = '\033[0;31m'
26 | TC_PASS = '\033[0;32m'
27 | TC_SKIP = '\033[0;33m'
28 | TEST_DIR = 'reconbf/modules/'
29 |
--------------------------------------------------------------------------------
/tests/test_nginx.py:
--------------------------------------------------------------------------------
1 | from reconbf.modules import test_nginx
2 | from reconbf.lib.result import Result
3 |
4 | import os
5 | import unittest
6 | from mock import patch
7 |
8 |
9 | class SslProtos(unittest.TestCase):
10 | def test_no_config(self):
11 | with patch.object(os.path, 'exists', return_value=False):
12 | res = test_nginx.ssl_protos(["ABC"])
13 | self.assertEqual(res.result, Result.SKIP)
14 |
15 |
16 | class SslCiphers(unittest.TestCase):
17 | def test_no_config(self):
18 | with patch.object(os.path, 'exists', return_value=False):
19 | res = test_nginx.ssl_ciphers(["ABC"])
20 | self.assertEqual(res.result, Result.SKIP)
21 |
22 |
23 | class SslCert(unittest.TestCase):
24 | def test_no_config(self):
25 | with patch.object(os.path, 'exists', return_value=False):
26 | res = test_nginx.ssl_cert()
27 | self.assertEqual(res.result, Result.SKIP)
28 |
29 |
30 | class VersionAdvertise(unittest.TestCase):
31 | def test_no_config(self):
32 | with patch.object(os.path, 'exists', return_value=False):
33 | res = test_nginx.version_advertise()
34 | self.assertEqual(res.result, Result.SKIP)
35 |
--------------------------------------------------------------------------------
/reconbf/modules/test_secureboot.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Hewlett Packard Enterprise Development LP
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | from reconbf.lib import test_class
16 | from reconbf.lib.result import Result, TestResult
17 |
18 |
19 | @test_class.explanation("""
20 | Protection name: SecureBoot
21 |
22 | Check: Ensures that the current system has been booted with the SecureBoot
23 | option active and it's been processed correctly.
24 |
25 | Purpose: Secure Boot works by placing the root of trust in firmware. It
26 | allows booting kernel/system verified by the EFI and hardware itself.
27 | """)
28 | def test_secureboot():
29 | EFI_DIR = '/sys/firmware/efi/efivars/'
30 | # SecureBoot from Global Efi Variables
31 | EFI_BOOT = EFI_DIR + 'SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c'
32 | try:
33 | with open(EFI_BOOT, 'rb') as f:
34 | data = f.read(5)
35 | except IOError:
36 | return TestResult(Result.SKIP,
37 | "EFI variables not available on the system")
38 |
39 | if len(data) != 5:
40 | # efivars contain 4 bytes of attributes + data
41 | return TestResult(Result.SKIP,
42 | "EFI variable does not contain data")
43 |
44 | if data[4:5] == b"\x01":
45 | return TestResult(Result.PASS,
46 | "SecureBoot is active")
47 | else:
48 | return TestResult(Result.FAIL,
49 | "SecureBoot is diabled")
50 |
--------------------------------------------------------------------------------
/reconbf/templates/results_template.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
ReconBF Run Results
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | | Test Name |
16 | Result |
17 | Notes |
18 |
19 |
20 | $$$RESULTS$$$
21 |
22 |
23 |
24 |
25 |
89 |
107 |
108 |
--------------------------------------------------------------------------------
/reconbf/modules/test_hardware.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Hewlett Packard Enterprise Development LP
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | import reconbf.lib.test_class as test_class
16 | from reconbf.lib.result import GroupTestResult
17 | from reconbf.lib.result import Result
18 | from reconbf.lib.result import TestResult
19 |
20 | import os
21 | import platform
22 |
23 |
24 | @test_class.explanation(
25 | """
26 | Protection name: USB authorization
27 |
28 | Check: Check if USB hosts accept all connected devices
29 |
30 | Purpose: Linux can ensure that USB devices are not active
31 | until they're explicitly authorized. Setting flag
32 | /sys/bus/usb/devices/usbX/authorized_default to 0 makes
33 | new devices disabled by default.
34 | This can protect against physical attacks via connected
35 | HID, storage, or exploitation device.
36 | """)
37 | def usb_authorization():
38 | if platform.system() != 'Linux':
39 | return TestResult(Result.SKIP, "available only on Linux")
40 |
41 | open_hosts = []
42 | hosts = [dev for dev in os.listdir('/sys/bus/usb/devices') if
43 | dev.startswith('usb')]
44 |
45 | for host in hosts:
46 | auth_file = os.path.join('/sys/bus/usb/devices', host,
47 | 'authorized_default')
48 | if not os.path.isfile(auth_file):
49 | continue
50 |
51 | with open(auth_file, 'r') as f:
52 | contents = f.read().strip()
53 |
54 | if contents != '0':
55 | open_hosts.append(host)
56 |
57 | if not hosts:
58 | return TestResult(Result.SKIP, "no USB hosts found")
59 |
60 | if not open_hosts:
61 | return TestResult(Result.PASS, "no open USB hosts")
62 |
63 | results = GroupTestResult()
64 | for host in open_hosts:
65 | results.add_result(host, TestResult(
66 | Result.FAIL, "USB host accepts all devices by default"))
67 | return results
68 |
--------------------------------------------------------------------------------
/reconbf/modules/test_package_support.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Hewlett Packard Enterprise Development LP
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | import reconbf.lib.test_class as test_class
16 | from reconbf.lib.result import TestResult
17 | from reconbf.lib.result import Result
18 | import platform
19 | import subprocess
20 |
21 |
22 | def _check_packages_ubuntu():
23 | res = subprocess.check_output(['ubuntu-support-status',
24 | '--show-unsupported'])
25 | lines = res.splitlines()
26 |
27 | for line in lines:
28 | if not line.startswith(b'You have '):
29 | continue
30 | if b'that are unsupported' not in line:
31 | continue
32 |
33 | if line.startswith(b"You have 0 packages"):
34 | return TestResult(Result.PASS, "Only supported packages installed")
35 | else:
36 | if bytes is not str:
37 | line = line.decode('utf-8', errors='replace')
38 | return TestResult(Result.FAIL, line.strip())
39 |
40 | return TestResult(Result.FAIL, "Unexpected ubuntu-support-status response")
41 |
42 |
43 | @test_class.explanation(
44 | """
45 | Protection name: Supported packages
46 |
47 | Check: Ensures that all installed packages are still
48 | marked as supported.
49 |
50 | Purpose: Some distributions will mark the packages as supported
51 | either in specific versions, or for a specific period of time.
52 | Unsupported packages will not receive security updates, therefore
53 | they should be validated / replaced if possible.
54 | """)
55 | def test_supported_packages():
56 | try:
57 | distro, _version, _name = platform.linux_distribution()
58 | except Exception:
59 | return TestResult(Result.SKIP, "Could not detect distribution")
60 |
61 | if distro == 'Ubuntu':
62 | return _check_packages_ubuntu()
63 | else:
64 | return TestResult(Result.SKIP, "Unknown distribution")
65 |
--------------------------------------------------------------------------------
/tests/test_sec.py:
--------------------------------------------------------------------------------
1 | from reconbf.modules import test_sec
2 | from reconbf.lib.result import Result
3 | from reconbf.lib import utils
4 |
5 | import unittest
6 | from mock import patch
7 |
8 |
9 | class SysctlValues(unittest.TestCase):
10 | def test_match(self):
11 | with patch.object(utils, 'get_sysctl_value', return_value="bar"):
12 | res = test_sec.test_sysctl_values({"x": ("match", "bar")})
13 | self.assertEqual(res.result, Result.PASS)
14 |
15 | def test_not_match(self):
16 | with patch.object(utils, 'get_sysctl_value', return_value="bar"):
17 | res = test_sec.test_sysctl_values({"x": ("match", "xxx")})
18 | self.assertEqual(res.result, Result.FAIL)
19 |
20 | def test_one_of(self):
21 | with patch.object(utils, 'get_sysctl_value', return_value="bar"):
22 | res = test_sec.test_sysctl_values({"x": ("one_of", ["bar", "x"])})
23 | self.assertEqual(res.result, Result.PASS)
24 |
25 | def test_not_one_of(self):
26 | with patch.object(utils, 'get_sysctl_value', return_value="bar"):
27 | res = test_sec.test_sysctl_values({"x": ("one_of", ["x"])})
28 | self.assertEqual(res.result, Result.FAIL)
29 |
30 | def test_none_of(self):
31 | with patch.object(utils, 'get_sysctl_value', return_value="bar"):
32 | res = test_sec.test_sysctl_values({"x": ("none_of", ["x"])})
33 | self.assertEqual(res.result, Result.PASS)
34 |
35 | def test_not_none_of(self):
36 | with patch.object(utils, 'get_sysctl_value', return_value="bar"):
37 | res = test_sec.test_sysctl_values({"x": ("none_of", ["bar", "x"])})
38 | self.assertEqual(res.result, Result.FAIL)
39 |
40 | def test_at_least(self):
41 | with patch.object(utils, 'get_sysctl_value', return_value="10"):
42 | res = test_sec.test_sysctl_values({"x": ("at_least", "5")})
43 | self.assertEqual(res.result, Result.PASS)
44 |
45 | def test_not_at_least(self):
46 | with patch.object(utils, 'get_sysctl_value', return_value="0"):
47 | res = test_sec.test_sysctl_values({"x": ("at_least", "5")})
48 | self.assertEqual(res.result, Result.FAIL)
49 |
50 | def test_without_description(self):
51 | with patch.object(utils, 'get_sysctl_value', return_value="foo"):
52 | res = test_sec.test_sysctl_values({"x": ("match", "foo")})
53 | self.assertEqual(res.results[0]['name'], "x")
54 |
55 | def test_with_description(self):
56 | with patch.object(utils, 'get_sysctl_value', return_value="foo"):
57 | res = test_sec.test_sysctl_values({"x": ("match", "foo", "bar")})
58 | self.assertEqual(res.results[0]['name'], "bar")
59 |
--------------------------------------------------------------------------------
/reconbf/modules/test_access.py:
--------------------------------------------------------------------------------
1 | from reconbf.lib.logger import logger
2 | import reconbf.lib.test_class as test_class
3 | from reconbf.lib.result import Result
4 | from reconbf.lib.result import TestResult
5 | from reconbf.lib.result import GroupTestResult
6 | from reconbf.lib import utils
7 |
8 |
9 | @utils.idempotent
10 | def _get_login_defs_config():
11 | config = {}
12 |
13 | try:
14 | with open('/etc/login.defs', 'r') as f:
15 | for line in f:
16 | line = line.strip()
17 |
18 | if not line:
19 | continue
20 | if line.startswith('#'):
21 | continue
22 |
23 | try:
24 | key, val = line.split()
25 | config[key] = val
26 | except ValueError:
27 | logger.debug("could not parse '%s'", line)
28 | continue
29 | except EnvironmentError:
30 | logger.warning("cannot read the login.defs config")
31 | return None
32 |
33 | return config
34 |
35 |
36 | @test_class.explanation(
37 | """
38 | Protection name: Logging of failed logins
39 |
40 | Check: Make sure that login failures are logged
41 |
42 | Purpose: Failed logins provide evidence of attempted access,
43 | as well as other information which may be helpful in
44 | stopping brute force attacks on accounts.
45 | """)
46 | def failed_logins_logged():
47 | config = _get_login_defs_config()
48 | if config is None:
49 | return TestResult(Result.SKIP, "Failed to process the config")
50 |
51 | if config['FAILLOG_ENAB'] != 'yes':
52 | return TestResult(Result.FAIL, "Failing logins are not logged")
53 | else:
54 | return TestResult(Result.PASS, "Failed logins are logged")
55 |
56 |
57 | @test_class.explanation(
58 | """
59 | Protection name: Logging of SU/SG access
60 |
61 | Check: Make sure that super user actions are logged
62 |
63 | Purpose: Explicit calls to super user actions should be
64 | logged. While this doesn't provide a full accounting of
65 | the super user actions, it provides useful information in
66 | most situations.
67 | """)
68 | def su_logging():
69 | results = GroupTestResult()
70 |
71 | config = _get_login_defs_config()
72 | if config is None:
73 | return TestResult(Result.SKIP, "Failed to process the config")
74 |
75 | if config['SYSLOG_SU_ENAB'] != 'yes':
76 | result = TestResult(Result.FAIL, "actions not logged")
77 | else:
78 | result = TestResult(Result.PASS)
79 | results.add_result("su", result)
80 |
81 | if config['SYSLOG_SG_ENAB'] != 'yes':
82 | result = TestResult(Result.FAIL, "actions not logged")
83 | else:
84 | result = TestResult(Result.PASS)
85 | results.add_result("sg", result)
86 |
87 | return results
88 |
--------------------------------------------------------------------------------
/reconbf/modules/test_signing.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Hewlett Packard Enterprise Development LP
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | from reconbf.lib import test_class
16 | from reconbf.lib import utils
17 | from reconbf.lib.result import GroupTestResult, Result, TestResult
18 |
19 |
20 | @test_class.explanation("""
21 | Protection name: Kernel module signing
22 |
23 | Check: Kernel will check module signatures before loading.
24 |
25 | Check: Kernel will prevent unsigned modules from loading.
26 |
27 | Check: Kernel has not loaded any unsigned modules.
28 |
29 | Purpose: Preventing unsigned modules from loading can make sure that bad
30 | object is not loaded accidentally, or maliciously.
31 | """)
32 | def test_module_signing():
33 | results = GroupTestResult()
34 |
35 | enabled_check = "Module signature checking enabled"
36 | forced_check = "Module signature checking forced"
37 | tainted_check = "Present modules"
38 |
39 | if utils.kconfig_option("CONFIG_MODULE_SIG") == "y":
40 | result = TestResult(Result.PASS, notes="Enabled")
41 | available = True
42 | else:
43 | result = TestResult(Result.FAIL, notes="Disabled")
44 | available = False
45 |
46 | results.add_result(enabled_check, result)
47 | if not available:
48 | result = TestResult(Result.SKIP, notes="Not available")
49 | results.add_result(forced_check, result)
50 | results.add_result(tainted_check, result)
51 | return results
52 |
53 | if utils.kconfig_option("CONFIG_MODULE_SIG_FORCE") == "y":
54 | result = TestResult(Result.PASS, notes="Enabled")
55 | else:
56 | result = TestResult(Result.FAIL, notes="Disabled")
57 | results.add_result(forced_check, result)
58 |
59 | try:
60 | with open('/proc/sys/kernel/tainted', 'r') as f:
61 | contents = f.read()
62 | level = int(contents)
63 | if level & 8192:
64 | result = TestResult(Result.FAIL, notes="Unsigned module detected")
65 | else:
66 | result = TestResult(Result.PASS,
67 | notes="All loaded modules are signed")
68 | except (IOError, ValueError):
69 | result = TestResult(Result.FAIL, notes="Taint level cannot be read")
70 |
71 | results.add_result(tainted_check, result)
72 | return results
73 |
--------------------------------------------------------------------------------
/reconbf/modules/test_dns.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Hewlett Packard Enterprise Development LP
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | import reconbf.lib.test_class as test_class
16 | from reconbf.lib.result import TestResult
17 | from reconbf.lib.result import Result
18 | import socket
19 |
20 |
21 | @test_class.explanation(
22 | """
23 | Protection name: Default DNS domain name check.
24 |
25 | Check: Ensures that a default domain name is set.
26 |
27 | Purpose: A default hostname should be set in the
28 | /etc/hostname file, so that traffic can be routed
29 | to the proper host. In the event that a hostname is
30 | not set a user set this information which allows for
31 | possibly intercept and manipulate traffic or other
32 | types of malicious behavior on the local network.
33 | """)
34 | def test_dns_name():
35 | try:
36 | host = socket.gethostname()
37 | except Exception:
38 | return TestResult(Result.SKIP, notes='Unable to find hostname.')
39 |
40 | if host:
41 | result = Result.PASS
42 | notes = 'Default hostname is %s.' % host
43 | else:
44 | result = Result.FAIL
45 | notes = 'Hostname is empty!'
46 | return TestResult(result, notes)
47 |
48 |
49 | @test_class.explanation(
50 | """
51 | Protection name: Default DNS search domain check.
52 |
53 | Check: Ensures that an entry exists in /etc/resolv.conf
54 | for default DNS search domain.
55 |
56 | Purpose: Domain Name System (DNS) search domains should be
57 | set in the /etc/resolv.conf file as missing information can
58 | lead to potential exploitation such as insertion of entries
59 | that would result in name resolution requests to be serviced
60 | by a rogue DNS.
61 | """)
62 | def test_default_dns_search():
63 | text = 'search '
64 | try:
65 | fp = open('/etc/resolv.conf', 'r')
66 | except IOError:
67 | return TestResult(Result.SKIP, notes="File /etc/resolv.conf " +
68 | "can't be opened for reading!")
69 |
70 | f = fp.read()
71 |
72 | if f.find(text):
73 | result = Result.PASS
74 | notes = 'Default search domain exists.'
75 | else:
76 | result = Result.FAIL
77 | notes = 'Default search domain does not exist!'
78 | return TestResult(result, notes)
79 |
80 | fp.close()
81 |
--------------------------------------------------------------------------------
/reconbf/lib/config.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Hewlett Packard Enterprise Development LP
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | from .logger import logger
16 |
17 | import json
18 | import sys
19 |
20 | """
21 | This class is used to manage the main config for RBF. It should be loaded
22 | in the beginning of the run and then is globally accessible, but specific
23 | values should be retrieved using 'get_config'.
24 |
25 | If there are any problems loading the config (it can't be found or the JSON
26 | doesn't parse, we'll exit the program.
27 | """
28 |
29 | config = None
30 |
31 |
32 | _no_default = object()
33 |
34 |
35 | class ConfigNotFound(Exception):
36 | pass
37 |
38 |
39 | class Config:
40 | def __init__(self, config_file):
41 | # try to initialize config class from specified json config file
42 | try:
43 | json_data = json.load(config_file)
44 |
45 | except ValueError:
46 | logger.error("File [ %s ] does not appear to be valid JSON.",
47 | config_file)
48 | sys.exit(2)
49 |
50 | else:
51 | self._config = json_data
52 |
53 | def get_config(self, config_path, default=_no_default):
54 | """Function will return a specified section of json or value
55 |
56 | :param config_path: Path in JSON document to desired bit
57 | :returns: Value or section of data
58 | """
59 | levels = config_path.split('.')
60 |
61 | cur_item = self._config
62 | for level in levels:
63 | if level in cur_item:
64 | cur_item = cur_item[level]
65 | else:
66 | if default is _no_default:
67 | logger.info("Unable to get config value: %s", config_path)
68 | raise ConfigNotFound()
69 | else:
70 | return default
71 |
72 | return cur_item
73 |
74 | def get_configured_tests(self):
75 | """Return the dict of (module, test_names) for each configured test"""
76 | tests = {}
77 | for mod, m_tests in self._config.get('modules', {}).items():
78 | tests[mod] = list(m_tests)
79 |
80 | return tests
81 |
82 |
83 | def get_config(config_path, default=_no_default):
84 | return config.get_config(config_path, default)
85 |
86 |
87 | def get_configured_tests():
88 | return config.get_configured_tests()
89 |
--------------------------------------------------------------------------------
/config/docker.cfg:
--------------------------------------------------------------------------------
1 | {
2 | "paths":
3 | { "sysctl_path": "/proc/sys"
4 | },
5 |
6 | "output":
7 | { "terminal":
8 | { "term_color_end": "\\033[0;m" ,
9 | "term_color_fail": "\\033[0;31m" ,
10 | "term_color_pass": "\\033[0;32m" ,
11 | "term_color_skip": "\\033[0;33m"
12 | },
13 | "report_csv":
14 | { "csv_separator": "|"
15 | }
16 | },
17 |
18 | "html_template": "results_template.html",
19 |
20 | "modules": {
21 | "test_file_controls": {
22 | "test_perms_and_ownership": [
23 | { "file" : "/usr/lib/systemd/system/docker.service",
24 | "disallowed_perms" : "x,wx,wx",
25 | "owner": "root",
26 | "group": "root" },
27 |
28 | { "file" : "/usr/lib/systemd/system/docker-registry.service",
29 | "disallowed_perms" : "x,wx,wx",
30 | "owner": "root",
31 | "group": "root" },
32 |
33 | { "file" : "/usr/lib/systemd/system/docker.socket",
34 | "disallowed_perms" : "x,wx,wx",
35 | "owner": "root",
36 | "group": "root" },
37 |
38 | { "file" : "/etc/sysconfig/docker",
39 | "disallowed_perms" : "x,wx,wx",
40 | "owner": "root",
41 | "group": "root" },
42 |
43 | { "file" : "/etc/sysconfig/docker-network",
44 | "disallowed_perms" : "x,wx,wx",
45 | "owner": "root",
46 | "group": "root" },
47 |
48 | { "file" : "/etc/sysconfig/docker-registry",
49 | "disallowed_perms" : "x,wx,wx",
50 | "owner": "root",
51 | "group": "root" },
52 |
53 | { "file" : "/etc/sysconfig/docker-storage",
54 | "disallowed_perms" : "x,wx,wx",
55 | "owner": "root",
56 | "group": "root" },
57 |
58 | { "file" : "/etc/docker",
59 | "disallowed_perms" : ",w,w",
60 | "owner": "root",
61 | "group": "root" },
62 |
63 | { "file" : "/var/run/docker.sock",
64 | "disallowed_perms" : "x,x,rwx",
65 | "owner": "root",
66 | "group": "docker" }
67 | ],
68 |
69 | "test_perms_files_in_dir": [
70 | { "directory" : "/etc/docker/certs.d",
71 | "dir_disallowed_perms" : "x,wx,wx",
72 | "file_disallowed_perms" : "x,wx,wx",
73 | "owner": "root",
74 | "group": "docker" },
75 |
76 | { "directory" : "/etc/ssl/certs",
77 | "dir_disallowed_perms" : "x,wx,wx",
78 | "file_disallowed_perms" : "x,wx,wx",
79 | "owner": "root",
80 | "group": "root" }
81 | ]
82 | }
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/reconbf/modules/test_firewall.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Hewlett Packard Enterprise Development LP
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | from reconbf.lib import test_class
16 | from reconbf.lib.result import Result
17 | from reconbf.lib.result import TestResult
18 | from reconbf.lib import utils
19 |
20 | import collections
21 | import subprocess
22 |
23 |
24 | def _list_rules():
25 | try:
26 | output = subprocess.check_output(['iptables-save'])
27 | except (IOError, subprocess.CalledProcessError):
28 | # cannot get the list of rules for some reason
29 | return None
30 |
31 | lines = [line.strip() for line in output.splitlines()
32 | if not line.startswith(b'#')]
33 | return lines
34 |
35 |
36 | def _get_default_policy(rules):
37 | """Get the default policy for each table/chain."""
38 | tables = collections.defaultdict(dict)
39 | current_table = None
40 |
41 | for rule in rules:
42 | if rule.startswith(b'*'):
43 | current_table = rule[1:]
44 |
45 | if rule.startswith(b':'):
46 | parts = rule[1:].split()
47 | tables[current_table][parts[0]] = parts[1]
48 |
49 | return tables
50 |
51 |
52 | @test_class.explanation("""
53 | Protection name: Firewall whitelisting
54 |
55 | Check: Make sure that the firewall is configured to reject
56 | packets by default.
57 |
58 | Purpose: Creating whitelists is usually more secure than
59 | blacklists. Defaulting to dropping unknown traffic is a safer
60 | option in case of missed rules.
61 | """)
62 | def firewall_whitelisting():
63 | if not utils.have_command('iptables-save'):
64 | return TestResult(Result.SKIP, "iptables not available")
65 |
66 | rules = _list_rules()
67 | if rules is None:
68 | return TestResult(Result.SKIP, "Cannot retrieve iptables rules")
69 |
70 | targets = _get_default_policy(rules)
71 | if b'filter' not in targets:
72 | return TestResult(Result.SKIP, "Cannot find the filter table")
73 |
74 | failures = []
75 |
76 | filter_table = targets[b'filter']
77 | if b'INPUT' not in filter_table:
78 | return TestResult(Result.SKIP, "Filter table doesn't include INPUT")
79 | if b'FORWARD' not in filter_table:
80 | return TestResult(Result.SKIP, "Filter table doesn't include FORWARD")
81 |
82 | if filter_table[b'INPUT'] == b'ACCEPT':
83 | failures.append('INPUT')
84 | if filter_table[b'FORWARD'] == b'ACCEPT':
85 | failures.append('FORWARD')
86 |
87 | if failures:
88 | return TestResult(Result.FAIL,
89 | "The following chains accept packets by "
90 | "default: %s" % ', '.join(failures))
91 | else:
92 | return TestResult(Result.PASS, "Filter chains whitelist by default")
93 |
--------------------------------------------------------------------------------
/reconbf/modules/test_mem.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Hewlett Packard Enterprise Development LP
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | from reconbf.lib.logger import logger
16 | import reconbf.lib.test_class as test_class
17 | from reconbf.lib.result import Result
18 | from reconbf.lib.result import TestResult
19 | from reconbf.lib import utils
20 |
21 | from subprocess import check_output
22 |
23 |
24 | @test_class.explanation(
25 | """
26 | Protection name: NX (No-eXecute) protection is enabled
27 |
28 | Check: First line in the system dmesg indicates if it is enabled
29 |
30 | Purpose: The NX bit indicates if the architecture has marked
31 | specific areas of memory as non-executable (known as executable
32 | space protection) and helps preventing certain types of buffer
33 | overflow attacks.
34 | """)
35 | def test_NX():
36 | logger.debug("Checking if NX (or NX emulation) is present.")
37 |
38 | output = check_output(["dmesg"])
39 | if b'NX (Execute Disable) protection: active' in output:
40 | reason = "NX protection active in BIOS."
41 | logger.debug(reason)
42 | result = Result.PASS
43 |
44 | else:
45 | # not active
46 | reason = "NX protection disabled in BIOS."
47 | logger.debug(reason)
48 | result = Result.FAIL
49 |
50 | return TestResult(result, reason)
51 |
52 |
53 | @test_class.explanation(
54 | """
55 | Protection name: /dev/mem device blocks non-device access
56 |
57 | Check: in /proc/config.gz the CONFIG_STRICT_DEVMEM line is
58 | uncommented and set to equal 'y'
59 |
60 | Purpose: Some applications are built to require access to physical
61 | memory in the user-space (such as X windows), which was provided
62 | by the /dev/mem device. This check ensures that only other devices
63 | have access to the kernel memory, and thus does not allow a
64 | malicious user or program the ability to view or change data.
65 | """)
66 | def test_devmem():
67 | # initial configurations
68 | reason = " "
69 | logger.debug("Attempting to validate /dev/mem protection.")
70 | result = Result.FAIL # set fail by default?
71 |
72 | # check kernel config - CONFIG_STRICT_DEVMEM=y
73 | try:
74 | devmem_val = utils.kconfig_option('CONFIG_STRICT_DEVMEM')
75 |
76 | if devmem_val == 'y':
77 | reason = "/dev/mem protection is enabled."
78 | logger.debug(reason)
79 | result = Result.PASS
80 | elif devmem_val == 'n':
81 | reason = "/dev/mem protection is not enabled."
82 | logger.debug(reason)
83 | result = Result.FAIL
84 | else:
85 | result = Result.SKIP
86 | reason = "Cannot find the kernel config or option"
87 |
88 | except IOError as e:
89 | reason = "Error opening /proc/config.gz."
90 | logger.debug("Unable to open /proc/config.gz.\n"
91 | " Exception information: [ {} ]".format(e))
92 | result = Result.SKIP
93 |
94 | return TestResult(result, reason)
95 |
--------------------------------------------------------------------------------
/tests/test_neutron.py:
--------------------------------------------------------------------------------
1 | from reconbf.modules import test_neutron
2 | from reconbf.lib.result import Result, TestResult
3 | from reconbf.lib import utils
4 |
5 | import pwd
6 | import grp
7 | import unittest
8 | from mock import patch
9 |
10 |
11 | class ConfigPermissions(unittest.TestCase):
12 | conf = {'dir': '', 'user': '', 'group': ''}
13 | pwd_root = pwd.struct_passwd(('root', 'x', 0, 0, 'root', '/root',
14 | '/bin/bash'))
15 | grp_root = grp.struct_group(('root', 'x', 0, []))
16 |
17 | def test_no_user(self):
18 | with patch.object(pwd, 'getpwnam', side_effect=KeyError()):
19 | with patch.object(grp, 'getgrnam', return_value=self.grp_root):
20 | res = test_neutron.config_permission(self.conf)
21 | self.assertEqual(res.result, Result.SKIP)
22 |
23 | def test_no_group(self):
24 | with patch.object(pwd, 'getpwnam', return_value=self.pwd_root):
25 | with patch.object(grp, 'getgrnam', side_effect=KeyError()):
26 | res = test_neutron.config_permission(self.conf)
27 | self.assertEqual(res.result, Result.SKIP)
28 |
29 | def test_good_perm(self):
30 | with patch.object(pwd, 'getpwnam', return_value=self.pwd_root):
31 | with patch.object(grp, 'getgrnam', return_value=self.grp_root):
32 | with patch.object(utils, 'validate_permissions',
33 | return_value=TestResult(Result.PASS)):
34 | res = test_neutron.config_permission(self.conf)
35 | self.assertEqual(res.result, Result.PASS)
36 |
37 | def test_bad_perm(self):
38 | with patch.object(pwd, 'getpwnam', return_value=self.pwd_root):
39 | with patch.object(grp, 'getgrnam', return_value=self.grp_root):
40 | with patch.object(utils, 'validate_permissions',
41 | return_value=TestResult(Result.FAIL)):
42 | res = test_neutron.config_permission(self.conf)
43 | self.assertEqual(res.result, Result.FAIL)
44 |
45 |
46 | class _ConfigTest():
47 | conf = {'dir': ''}
48 |
49 | def test_no_config(self):
50 | with patch.object(utils, 'parse_openstack_ini',
51 | side_effect=EnvironmentError()):
52 | if hasattr(self.func, 'im_func'):
53 | res = self.func.im_func(self.conf)
54 | else:
55 | res = self.func.__func__(self.conf)
56 | self.assertEqual(res.result, Result.SKIP)
57 |
58 | def _run_with_config(self, cfg):
59 | with patch.object(utils, 'parse_openstack_ini', return_value=cfg):
60 | if hasattr(self.func, 'im_func'):
61 | return self.func.im_func(self.conf)
62 | else:
63 | return self.func.__func__(self.conf)
64 |
65 | def test_ok(self):
66 | res = self._run_with_config(self.good_val)
67 | self.assertEqual(res.result, Result.PASS)
68 |
69 | def test_bad(self):
70 | res = self._run_with_config(self.bad_val)
71 | self.assertEqual(res.result, Result.FAIL)
72 |
73 |
74 | class Auth(_ConfigTest, unittest.TestCase):
75 | func = test_neutron.auth
76 | good_val = {'DEFAULT': {'auth_strategy': 'keystone'}}
77 | bad_val = {'DEFAULT': {'auth_strategy': 'noauth'}}
78 |
79 |
80 | class KeystoneSecure(_ConfigTest, unittest.TestCase):
81 | func = test_neutron.keystone_secure
82 | good_val = {'keystone_authtoken': {
83 | 'auth_protocol': 'https',
84 | 'identity_uri': 'https://example.com'}}
85 | bad_val = {'keystone_authtoken': {
86 | 'auth_protocol': 'http',
87 | 'identity_uri': 'https://example.com'}}
88 | bad_val2 = {'keystone_authtoken': {
89 | 'auth_protocol': 'https',
90 | 'identity_uri': 'http://example.com'}}
91 |
92 | def test_bad_2(self):
93 | res = self._run_with_config(self.bad_val2)
94 | self.assertEqual(res.result, Result.FAIL)
95 |
96 |
97 | class UseSsl(_ConfigTest, unittest.TestCase):
98 | func = test_neutron.use_ssl
99 | good_val = {'DEFAULT': {'use_ssl': 'true'}}
100 | bad_val = {'DEFAULT': {'use_ssl': 'false'}}
101 |
--------------------------------------------------------------------------------
/reconbf/modules/test_mysql.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Hewlett Packard Enterprise Development LP
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | from reconbf.lib import test_class
16 | from reconbf.lib import utils
17 | from reconbf.lib.result import GroupTestResult, TestResult, Result
18 |
19 | import os
20 |
21 |
22 | CONFIG_PATH = "/etc/mysql/mysql.cnf"
23 | INCLUDE_DIR_MARK = "!includedir "
24 | INCLUDE_FILE_MARK = "!include "
25 |
26 |
27 | def _get_incdir_config(path):
28 | included = []
29 | confs = [conf for conf in os.listdir(path) if conf.endswith('.cnf')]
30 | # there's no guarantee about the processing order, so just assume the user
31 | # makes reasonable choices here
32 | for conf in confs:
33 | inc_path = os.path.join(path, conf)
34 | included.extend(_get_full_config(inc_path))
35 | return included
36 |
37 |
38 | def _get_full_config(path):
39 | with open(path, 'r') as conf:
40 | conf_lines = conf.readlines()
41 |
42 | complete = []
43 | for line in conf_lines:
44 | stripped = line.strip()
45 | if stripped.startswith("#") or stripped.startswith(";"):
46 | # not necessary, but skipping comments will save some memory
47 | continue
48 |
49 | if stripped.startswith(INCLUDE_DIR_MARK):
50 | inc_path = stripped[len(INCLUDE_DIR_MARK):].strip()
51 | if not os.path.isabs(inc_path):
52 | inc_path = os.path.join(os.path.dirname(path), inc_path)
53 | complete.extend(_get_incdir_config(inc_path))
54 | continue
55 |
56 | if stripped.startswith(INCLUDE_FILE_MARK):
57 | inc_path = stripped[len(INCLUDE_FILE_MARK):].strip()
58 | if not os.path.isabs(inc_path):
59 | inc_path = os.path.join(os.path.dirname(path), inc_path)
60 | complete.extend(_get_full_config(inc_path))
61 | continue
62 |
63 | complete.append(line)
64 |
65 | return complete
66 |
67 |
68 | def _mysqld_default_config():
69 | return {
70 | "mysqld.allow-suspicious-udfs": {"disallowed": ["1"]},
71 | "mysqld.safe-user-create": {"allowed": ["1", ""]},
72 | "mysqld.secure-auth": {"disallowed": ["0"]},
73 | "mysqld.skip-secure-auth": {"disallowed": "*"},
74 | "mysqld.skip-grant-tables": {"disallowed": "*"},
75 | "mysqld.skip-show-database": {"allowed": ["1"]},
76 | }
77 |
78 |
79 | @test_class.takes_config(_mysqld_default_config)
80 | @test_class.explanation(
81 | """
82 | Protection name: Secure mysql configuration
83 |
84 | Check: Validates the security options in mysql configuration.
85 |
86 | Purpose: The following options are included by default:
87 | - allow-suspicious-udfs: prevents loading and use of unexpected functions
88 | - safe-user-create: extra protection on user grants manipulation
89 | - secure-auth: disable old authentication methods
90 | - skip-grant-tables: ensure authentication is applied
91 | - skip-show-database: in production there's no reason to discover databases
92 | """)
93 | def safe_config(expected_config):
94 | if not os.path.exists(CONFIG_PATH):
95 | return TestResult(Result.SKIP, "MySQL config not found")
96 |
97 | try:
98 | config_lines = _get_full_config(CONFIG_PATH)
99 | except IOError:
100 | return TestResult(Result.FAIL, "MySQL config could not be read")
101 | results = GroupTestResult()
102 | for test, res in utils.verify_config(
103 | CONFIG_PATH, config_lines, expected_config, keyval_delim='='):
104 | results.add_result(test, res)
105 | return results
106 |
--------------------------------------------------------------------------------
/tests/test_manila.py:
--------------------------------------------------------------------------------
1 | from reconbf.modules import test_manila
2 | from reconbf.lib.result import Result, TestResult
3 | from reconbf.lib import utils
4 |
5 | import pwd
6 | import grp
7 | import unittest
8 | from mock import patch
9 |
10 |
11 | class ConfigPermissions(unittest.TestCase):
12 | conf = {'dir': '', 'user': '', 'group': ''}
13 | pwd_root = pwd.struct_passwd(('root', 'x', 0, 0, 'root', '/root',
14 | '/bin/bash'))
15 | grp_root = grp.struct_group(('root', 'x', 0, []))
16 |
17 | def test_no_user(self):
18 | with patch.object(pwd, 'getpwnam', side_effect=KeyError()):
19 | with patch.object(grp, 'getgrnam', return_value=self.grp_root):
20 | res = test_manila.config_permission(self.conf)
21 | self.assertEqual(res.result, Result.SKIP)
22 |
23 | def test_no_group(self):
24 | with patch.object(pwd, 'getpwnam', return_value=self.pwd_root):
25 | with patch.object(grp, 'getgrnam', side_effect=KeyError()):
26 | res = test_manila.config_permission(self.conf)
27 | self.assertEqual(res.result, Result.SKIP)
28 |
29 | def test_good_perm(self):
30 | with patch.object(pwd, 'getpwnam', return_value=self.pwd_root):
31 | with patch.object(grp, 'getgrnam', return_value=self.grp_root):
32 | with patch.object(utils, 'validate_permissions',
33 | return_value=TestResult(Result.PASS)):
34 | res = test_manila.config_permission(self.conf)
35 | self.assertEqual(res.result, Result.PASS)
36 |
37 | def test_bad_perm(self):
38 | with patch.object(pwd, 'getpwnam', return_value=self.pwd_root):
39 | with patch.object(grp, 'getgrnam', return_value=self.grp_root):
40 | with patch.object(utils, 'validate_permissions',
41 | return_value=TestResult(Result.FAIL)):
42 | res = test_manila.config_permission(self.conf)
43 | self.assertEqual(res.result, Result.FAIL)
44 |
45 |
46 | class _ConfigTest():
47 | conf = {'dir': ''}
48 |
49 | def test_no_config(self):
50 | with patch.object(utils, 'parse_openstack_ini',
51 | side_effect=EnvironmentError()):
52 | if hasattr(self.func, 'im_func'):
53 | res = self.func.im_func(self.conf)
54 | else:
55 | res = self.func.__func__(self.conf)
56 | self.assertEqual(res.result, Result.SKIP)
57 |
58 | def _run_with_config(self, cfg):
59 | with patch.object(utils, 'parse_openstack_ini', return_value=cfg):
60 | if hasattr(self.func, 'im_func'):
61 | return self.func.im_func(self.conf)
62 | else:
63 | return self.func.__func__(self.conf)
64 |
65 | def test_ok(self):
66 | res = self._run_with_config(self.good_val)
67 | self.assertEqual(res.result, Result.PASS)
68 |
69 | def test_bad(self):
70 | res = self._run_with_config(self.bad_val)
71 | self.assertEqual(res.result, Result.FAIL)
72 |
73 |
74 | class Auth(_ConfigTest, unittest.TestCase):
75 | func = test_manila.auth
76 | good_val = {'DEFAULT': {'auth_strategy': 'keystone'}}
77 | bad_val = {'DEFAULT': {'auth_strategy': 'noauth'}}
78 |
79 |
80 | class KeystoneSecure(_ConfigTest, unittest.TestCase):
81 | func = test_manila.keystone_secure
82 | good_val = {'keystone_authtoken': {
83 | 'auth_protocol': 'https',
84 | 'identity_uri': 'https://example.com'}}
85 | bad_val = {'keystone_authtoken': {
86 | 'auth_protocol': 'http',
87 | 'identity_uri': 'https://example.com'}}
88 | bad_val2 = {'keystone_authtoken': {
89 | 'auth_protocol': 'https',
90 | 'identity_uri': 'http://example.com'}}
91 |
92 | def test_bad_2(self):
93 | res = self._run_with_config(self.bad_val2)
94 | self.assertEqual(res.result, Result.FAIL)
95 |
96 |
97 | class NovaSecure(_ConfigTest, unittest.TestCase):
98 | func = test_manila.nova_secure
99 | good_val = {'DEFAULT': {'nova_api_insecure': 'false'}}
100 | bad_val = {'DEFAULT': {'nova_api_insecure': 'true'}}
101 |
102 |
103 | class NeutronSecure(_ConfigTest, unittest.TestCase):
104 | func = test_manila.neutron_secure
105 | good_val = {'DEFAULT': {'neutron_api_insecure': 'false'}}
106 | bad_val = {'DEFAULT': {'neutron_api_insecure': 'true'}}
107 |
108 |
109 | class CinderSecure(_ConfigTest, unittest.TestCase):
110 | func = test_manila.cinder_secure
111 | good_val = {'DEFAULT': {'cinder_api_insecure': 'false'}}
112 | bad_val = {'DEFAULT': {'cinder_api_insecure': 'true'}}
113 |
114 |
115 | class BodySize(_ConfigTest, unittest.TestCase):
116 | func = test_manila.body_size
117 | good_val = {'DEFAULT': {'osapi_max_request_body_size': '114688'},
118 | 'oslo_middleware': {'max_request_body_size': '114688'}}
119 | bad_val = {'DEFAULT': {'osapi_max_request_body_size': '114689'},
120 | 'oslo_middleware': {'max_request_body_size': '114689'}}
121 |
--------------------------------------------------------------------------------
/reconbf/modules/test_mounts.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Hewlett Packard Enterprise Development LP
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | from reconbf.lib.logger import logger
16 | from reconbf.lib import test_class
17 | from reconbf.lib.result import GroupTestResult
18 | from reconbf.lib.result import Result
19 | from reconbf.lib.result import TestResult
20 | from reconbf.lib import utils
21 |
22 | import os
23 | import re
24 | import subprocess
25 |
26 |
27 | MOUNT_RE_LINUX = re.compile(b"""
28 | (.+) # source
29 | \s on \s
30 | (.+) # destination
31 | \s type \s
32 | (.+) # type
33 | \s
34 | \(([^)]*)\) # options
35 | """, re.VERBOSE)
36 | MOUNT_RE_BSD = re.compile(b"""
37 | (.+) # source
38 | \s on \s
39 | (.+) # destination
40 | \s \(
41 | ([^,]*) # type
42 | ([^)]*)\) # options
43 | """, re.VERBOSE)
44 |
45 |
46 | @utils.idempotent
47 | def _get_mounts():
48 | try:
49 | mounts = subprocess.check_output(['mount'])
50 | except (subprocess.CalledProcessError, OSError):
51 | return None
52 |
53 | results = []
54 |
55 | for mount in mounts.splitlines():
56 | for RE in (MOUNT_RE_LINUX, MOUNT_RE_BSD):
57 | m = RE.match(mount.strip())
58 | if m:
59 | break
60 | if not m:
61 | logger.warning("could not parse mount line '%s'", mount)
62 | continue
63 | results.append((
64 | m.group(1),
65 | m.group(2),
66 | m.group(3),
67 | m.group(4).split(b',')[1:],
68 | ))
69 |
70 | return results
71 |
72 |
73 | def _find_mount_point(mounts, path):
74 | candidate = None
75 |
76 | for mount in mounts:
77 | common_prefix = os.path.commonprefix([mount[1], path])
78 | if common_prefix == path:
79 | return mount
80 |
81 | if common_prefix == mount[1]:
82 | if not candidate:
83 | candidate = mount
84 | else:
85 | if len(candidate[1]) < len(mount[1]):
86 | candidate = mount
87 |
88 | # None will never be returned in practice - at least / will match
89 | return candidate
90 |
91 |
92 | def _conf_nosuid():
93 | return ['/dev', '/dev/pts', '/dev/shm', '/home', '/proc', '/run', '/sys',
94 | '/tmp']
95 |
96 |
97 | @test_class.explanation("""
98 | Protection name: Directories mounted with nosuid
99 |
100 | Check: Verify whether configured directories are mounted
101 | with a nosuid option.
102 |
103 | Purpose: Most directories are not expected to hold
104 | setuid/setgid binaries. Turning on the nosuid option on their
105 | mount entries ensures the system is hardened against some
106 | exploits relying on local file manipulation.
107 | """)
108 | @test_class.takes_config(_conf_nosuid)
109 | def no_suid(nosuid_mounts):
110 | mounts = _get_mounts()
111 |
112 | results = GroupTestResult()
113 | for destination in nosuid_mounts:
114 | point = _find_mount_point(mounts, destination.encode('utf-8'))
115 |
116 | if b'nosuid' in point[3]:
117 | results.add_result(destination, TestResult(Result.PASS))
118 | else:
119 | dest = point[1].decode('utf-8', errors='replace')
120 | msg = "suid binaries allowed on %s" % (dest,)
121 | results.add_result(destination, TestResult(Result.FAIL, msg))
122 | return results
123 |
124 |
125 | def _conf_noexec():
126 | return ['/proc', '/run', '/sys', '/tmp']
127 |
128 |
129 | @test_class.explanation("""
130 | Protection name: Directories mounted with noexec
131 |
132 | Check: Verify whether configured directories are mounted
133 | with a noexec option.
134 |
135 | Purpose: Most directories are not expected to hold
136 | executable binaries. Turning on the noexec option on their
137 | mount entries ensures the system is hardened against some
138 | exploits relying on local file manipulation.
139 | """)
140 | @test_class.takes_config(_conf_noexec)
141 | def no_exec(noexec_mounts):
142 | mounts = _get_mounts()
143 |
144 | results = GroupTestResult()
145 | for destination in noexec_mounts:
146 | point = _find_mount_point(mounts, destination.encode('utf-8'))
147 |
148 | if b'noexec' in point[3]:
149 | results.add_result(destination, TestResult(Result.PASS))
150 | else:
151 | dest = point[1].decode('utf-8', errors='replace')
152 | msg = "executable files allowed on %s" % (dest,)
153 | results.add_result(destination, TestResult(Result.FAIL, msg))
154 | return results
155 |
--------------------------------------------------------------------------------
/tests/test_horizon.py:
--------------------------------------------------------------------------------
1 | from reconbf.modules import test_horizon
2 | from reconbf.lib.result import Result, TestResult
3 | from reconbf.lib import utils
4 |
5 | import pwd
6 | import grp
7 | import unittest
8 | from mock import patch
9 |
10 |
11 | class ConfigPermissions(unittest.TestCase):
12 | conf = {'dir': '', 'user': '', 'group': ''}
13 | pwd_root = pwd.struct_passwd(('root', 'x', 0, 0, 'root', '/root',
14 | '/bin/bash'))
15 | grp_root = grp.struct_group(('root', 'x', 0, []))
16 |
17 | def test_no_user(self):
18 | with patch.object(pwd, 'getpwnam', side_effect=KeyError()):
19 | with patch.object(grp, 'getgrnam', return_value=self.grp_root):
20 | res = test_horizon.config_permission(self.conf)
21 | self.assertEqual(res.result, Result.SKIP)
22 |
23 | def test_no_group(self):
24 | with patch.object(pwd, 'getpwnam', return_value=self.pwd_root):
25 | with patch.object(grp, 'getgrnam', side_effect=KeyError()):
26 | res = test_horizon.config_permission(self.conf)
27 | self.assertEqual(res.result, Result.SKIP)
28 |
29 | def test_good_perm(self):
30 | with patch.object(pwd, 'getpwnam', return_value=self.pwd_root):
31 | with patch.object(grp, 'getgrnam', return_value=self.grp_root):
32 | with patch.object(utils, 'validate_permissions',
33 | return_value=TestResult(Result.PASS)):
34 | res = test_horizon.config_permission(self.conf)
35 | self.assertEqual(res.result, Result.PASS)
36 |
37 | def test_bad_perm(self):
38 | with patch.object(pwd, 'getpwnam', return_value=self.pwd_root):
39 | with patch.object(grp, 'getgrnam', return_value=self.grp_root):
40 | with patch.object(utils, 'validate_permissions',
41 | return_value=TestResult(Result.FAIL)):
42 | res = test_horizon.config_permission(self.conf)
43 | self.assertEqual(res.result, Result.FAIL)
44 |
45 |
46 | class _ConfigTest():
47 | conf = {'dir': ''}
48 |
49 | def test_no_config(self):
50 | with patch.object(test_horizon, '_read_config',
51 | side_effect=EnvironmentError()):
52 | if hasattr(self.func, 'im_func'):
53 | res = self.func.im_func(self.conf)
54 | else:
55 | res = self.func.__func__(self.conf)
56 | self.assertEqual(res.result, Result.SKIP)
57 |
58 | def _run_with_config(self, cfg):
59 | with patch.object(test_horizon, '_read_config', return_value=cfg):
60 | if hasattr(self.func, 'im_func'):
61 | return self.func.im_func(self.conf)
62 | else:
63 | return self.func.__func__(self.conf)
64 |
65 | def test_ok(self):
66 | res = self._run_with_config({self.option: self.good_val})
67 | self.assertEqual(res.result, Result.PASS)
68 |
69 | def test_bad(self):
70 | res = self._run_with_config({self.option: self.bad_val})
71 | self.assertEqual(res.result, Result.FAIL)
72 |
73 |
74 | class CsrfCookie(_ConfigTest, unittest.TestCase):
75 | func = test_horizon.csrf_cookie
76 | good_val = True
77 | bad_val = False
78 | option = 'CSRF_COOKIE_SECURE'
79 |
80 |
81 | class SessionCookie(_ConfigTest, unittest.TestCase):
82 | func = test_horizon.session_cookie
83 | good_val = True
84 | bad_val = False
85 | option = 'SESSION_COOKIE_SECURE'
86 |
87 |
88 | class SessionCookieHttp(_ConfigTest, unittest.TestCase):
89 | func = test_horizon.session_cookie_http
90 | good_val = True
91 | bad_val = False
92 | option = 'SESSION_COOKIE_HTTPONLY'
93 |
94 |
95 | class PasswordAutocomplete(_ConfigTest, unittest.TestCase):
96 | func = test_horizon.password_autocomplete
97 | good_val = 'off'
98 | bad_val = 'on'
99 | option = 'HORIZON_CONFIG[password_autocomplete]'
100 |
101 |
102 | class PasswordReveal(_ConfigTest, unittest.TestCase):
103 | func = test_horizon.password_reveal
104 | good_val = True
105 | bad_val = False
106 | option = 'HORIZON_CONFIG[disable_password_reveal]'
107 |
108 |
109 | class Parser(unittest.TestCase):
110 | def test_empty(self):
111 | conf = test_horizon._parse_config("")
112 | self.assertEqual(conf, {})
113 |
114 | def test_ignore_imports(self):
115 | conf = test_horizon._parse_config("import blah")
116 | self.assertEqual(conf, {})
117 |
118 | def test_assign_basic(self):
119 | conf = test_horizon._parse_config(
120 | """a=0;b="str";c=(1,2);d=[1,2];e={1:2};f={1,2}""")
121 | self.assertEqual(conf, {'a': 0, 'b': 'str', 'c': (1, 2), 'd': [1, 2],
122 | 'e': {1: 2}, 'f': {1, 2}})
123 |
124 | def test_assign_slice(self):
125 | conf = test_horizon._parse_config("a['b']=1")
126 | self.assertEqual(conf, {'a[b]': 1})
127 |
128 | def test_ignore_destruct(self):
129 | # destructuring is hard and unlikely to be needed
130 | conf = test_horizon._parse_config("a,b=1,2;c=3")
131 | self.assertEqual(conf, {'c': 3})
132 |
133 | def test_ignore_non_const(self):
134 | # skip non-constant expressions
135 | conf = test_horizon._parse_config("a=func();c=3")
136 | self.assertEqual(conf, {'c': 3})
137 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | Recon by fire
2 | =============
3 |
4 | Recon is a tool for reviewing the security configuration of a local system. It
5 | can detect existing issues, known-insecure settings, existing strange behaviour,
6 | and options for further hardening.
7 |
8 | Recon can be used in existing systems to find out which elements can be improved
9 | and can provide some information about why the change is recommended. It can
10 | also be used to scan prepared system images to verify that they contain the
11 | expected protection.
12 |
13 |
14 | What can Recon help with
15 | ------------------------
16 |
17 | Recon checks:
18 |
19 | - sysctl settings
20 | - application configs
21 | - security features used in compiled binaries
22 | - security features of current kernel
23 | - suspicious system conditions (like upgraded binaries which have not been
24 | restarted)
25 | - and many others
26 |
27 | Recon is most useful for verifying that the system security is configured as
28 | expected and for spotting hardening opportunities.
29 |
30 |
31 | What Recon isn't
32 | ----------------
33 |
34 | System integrity checker - although it can be used to check the results or any
35 | such system.
36 |
37 | Rootkit detector - Recon uses only the most strightforward way to verify the
38 | system state. It does not try to detect existing hidden or malicious elements.
39 |
40 | Intrusion detection system - it will not attempt to detect active attackers.
41 |
42 |
43 | Recon usage
44 | -----------
45 |
46 | Recon requires root privileges on the system to run most of its tests. All the
47 | system access is readonly however - no changes are made during the run and Recon
48 | should not affect processes on a production system.
49 |
50 | ::
51 |
52 | usage: reconbf [-h] [-c CONFIG_FILE] [-g {default,inline}]
53 | [-l--level {debug,info,error}] [-rf REPORT_FILE]
54 | [-rt {csv,json,html}] [-dm {all,fail,overall,notpass}]
55 |
56 | ReconBF - a Python OS security feature tester
57 |
58 | optional arguments:
59 | -h, --help show this help message and exit
60 | -c CONFIG_FILE, --config CONFIG_FILE
61 | use specified config file instead of default
62 | -g {default,inline}, --generate {default,inline}
63 | generates config file contetns with all the available
64 | modules listed and either configured to use the config
65 | that comes with the test, or inlines the current
66 | default configuration
67 | -l--level {debug,info,error}
68 | log level: can be "debug", "info", or "error"
69 | default=info
70 | -rf REPORT_FILE, --reportfile REPORT_FILE
71 | output file: default=result.out
72 | -rt {csv,json,html}, --reporttype {csv,json,html}
73 | output type: can be "csv", "json", or "html"
74 | -dm {all,fail,overall,notpass}, --displaymode {all,fail,overall,notpass}
75 | controls how tests are displayed: all-displays all
76 | results, fail-displays only tests which failed,
77 | overall-displays parent test statuses only, notpass-
78 | displays any test which didn't pass
79 |
80 | The default way to run Recon is just `python -m reconbf` or install it and run
81 | `reconbf` (both with `sudo` if running as a non-root user).
82 |
83 | If you need to adjust the configuration or verify your system against only a
84 | specific set of tests, you can generate a new configuration file using `-g
85 | inline` option. The resulting configuration will include all the available
86 | modules and also the default module configuration where needed.
87 |
88 |
89 | Interpreting results
90 | --------------------
91 |
92 | Some tests will result in a very clear answer. For example `test_sysctl_values`
93 | is going to always give the real answer coming from the `sysctl` output.
94 |
95 | Other tests may not be that clear, or may be skipped when some system elements
96 | are not reachable. For example `test_ptrace_scope` depends on kernel config
97 | being available on the system and matching the currently deployed kernel. While
98 | this is the usual and expected state, any failures or skipped tests should be
99 | investigated separately and understood before taking actions to correct them.
100 |
101 | Other tests may rely on information which is not always available. For example
102 | `test_binaries` will attempt to check whether some binaries were compiled with
103 | stack protection. While this check will not have false-positives, it may report
104 | a false-negative if the analysed binary was compiled with `-fstack-protector`
105 | (not `-fstack-protector-all`) and gcc decides that none of the functions
106 | contained buffers that require protection.
107 |
108 |
109 | Module development
110 | ------------------
111 |
112 | While developing new modules, please keep the following in mind:
113 |
114 | - ensure the code style matches (partially enforced by flake8 already)
115 | - new modules should come with unittests for them
116 | - new modules should not do direct IO operations; files or processes should be
117 | opened by either general abstractions in `reconbf.utils`, or local helpers in
118 | separate functions - this is to help writing small tests
119 |
120 |
121 | License
122 | -------
123 | reconbf is released under Apache 2.0 license.
124 |
--------------------------------------------------------------------------------
/reconbf/modules/test_neutron.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Hewlett Packard Enterprise Development LP
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | from reconbf.lib import test_class
16 | from reconbf.lib.result import GroupTestResult
17 | from reconbf.lib.result import Result
18 | from reconbf.lib.result import TestResult
19 | from reconbf.lib import utils
20 | import grp
21 | import os
22 | import pwd
23 | import functools
24 |
25 |
26 | def _conf_location():
27 | return {'dir': '/etc/neutron'}
28 |
29 |
30 | def _conf_details():
31 | config = _conf_location().copy()
32 | config['user'] = 'root'
33 | config['group'] = 'neutron'
34 | return config
35 |
36 |
37 | @test_class.explanation("""
38 | Protection name: Config permissions
39 |
40 | Check: Are manila config permissions ok
41 |
42 | Purpose: Manila config files contain authentication
43 | details and need to be protected. Ensure that
44 | they're only available to the service.
45 | """)
46 | @test_class.set_mapping("OpenStack:Check-Neutron-01",
47 | "OpenStack:Check-Neutron-02")
48 | @test_class.takes_config(_conf_details)
49 | def config_permission(config):
50 | try:
51 | user = pwd.getpwnam(config['user'])
52 | except KeyError:
53 | return TestResult(Result.SKIP,
54 | 'Could not find user "%s"' % config['user'])
55 |
56 | try:
57 | group = grp.getgrnam(config['group'])
58 | except KeyError:
59 | return TestResult(Result.SKIP,
60 | 'Could not find group "%s"' % config['group'])
61 |
62 | result = GroupTestResult()
63 | files = ['neutron.conf', 'api-paste.ini', 'policy.json', 'rootwrap.conf']
64 | for f in files:
65 | path = os.path.join(config['dir'], f)
66 | result.add_result(path,
67 | utils.validate_permissions(path, 0o640, user.pw_uid,
68 | group.gr_gid))
69 | return result
70 |
71 |
72 | def _checks_config(f):
73 | @functools.wraps(f)
74 | def wrapper(config):
75 | try:
76 | path = os.path.join(config['dir'], 'neutron.conf')
77 | conf = utils.parse_openstack_ini(path)
78 | except EnvironmentError:
79 | return TestResult(Result.SKIP, 'cannot read neutron config file')
80 | return f(conf)
81 | return wrapper
82 |
83 |
84 | @test_class.explanation("""
85 | Protection name: Authentication strategy
86 |
87 | Check: Make sure proper authentication is used
88 |
89 | Purpose: There are multiple authentication backends
90 | available. Neutron should be configured to authenticate
91 | against keystone rather than test backends.
92 | """)
93 | @test_class.set_mapping("OpenStack:Check-Neutron-03")
94 | @test_class.takes_config(_conf_location)
95 | @_checks_config
96 | def auth(conf):
97 | auth = conf.get('DEFAULT', {}).get('auth_strategy', 'keystone')
98 | if auth != 'keystone':
99 | return TestResult(Result.FAIL,
100 | 'authentication should be done by keystone')
101 | else:
102 | return TestResult(Result.PASS)
103 |
104 |
105 | @test_class.explanation("""
106 | Protection name: Keystone api access
107 |
108 | Check: Does Keystone access use secure connection
109 |
110 | Purpose: OpenStack components communicate with each other
111 | using various protocols and the communication might
112 | involve sensitive / confidential data. An attacker may
113 | try to eavesdrop on the channel in order to get access to
114 | sensitive information. Thus all the components must
115 | communicate with each other using a secured communication
116 | protocol.
117 | """)
118 | @test_class.set_mapping("OpenStack:Check-Neutron-04")
119 | @test_class.takes_config(_conf_location)
120 | @_checks_config
121 | def keystone_secure(conf):
122 | protocol = conf.get('keystone_authtoken', {}).get('auth_protocol', 'https')
123 | identity = conf.get('keystone_authtoken', {}).get('identity_uri', 'https:')
124 |
125 | if not identity.startswith('https:'):
126 | return TestResult(Result.FAIL, 'keystone access is not secure')
127 | if protocol != 'https':
128 | return TestResult(Result.FAIL, 'keystone access is not secure')
129 |
130 | return TestResult(Result.PASS)
131 |
132 |
133 | @test_class.explanation("""
134 | Protection name: Secure API access
135 |
136 | Check: Does Neutron API expect SSL connections
137 |
138 | Purpose: Neutron requests include sensitive information.
139 | Using encrypted channel prevents exposing it to anyone
140 | capturing the traffic.
141 | """)
142 | @test_class.set_mapping("OpenStack:Check-Neutron-05")
143 | @test_class.takes_config(_conf_location)
144 | @_checks_config
145 | def use_ssl(conf):
146 | ssl = conf.get('DEFAULT', {}).get('use_ssl', 'False')
147 | ssl = ssl.lower() == 'true'
148 |
149 | if ssl:
150 | return TestResult(Result.PASS)
151 | else:
152 | return TestResult(Result.FAIL, 'SSL not used for the neutron API')
153 |
--------------------------------------------------------------------------------
/reconbf/modules/test_mac.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Hewlett Packard Enterprise Development LP
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | from reconbf.lib.logger import logger
16 | import reconbf.lib.test_class as test_class
17 | from reconbf.lib.result import Result
18 | from reconbf.lib.result import TestResult
19 | from reconbf.lib import utils
20 |
21 | from subprocess import PIPE
22 | from subprocess import Popen
23 |
24 |
25 | @test_class.explanation(
26 | """
27 | Protection name: SELinux installed
28 |
29 | Check: Run sestatus command and check the output
30 |
31 | Purpose: Mandatory Access Controls such as AppArmor and SELinux should be
32 | installed in order to confine applications to the leas privilege required
33 | to run them.
34 | """)
35 | @utils.linux_specific
36 | def test_selinux():
37 | """Uses return from sestatus command to ensure SELinux is installed
38 |
39 | :returns: A TestResult object containing the result and, on failure,
40 | notes explaining why it did not pass.
41 | """
42 |
43 | # logger
44 | return_result = None
45 | logger.debug("Attempting to validate SELinux is installed.")
46 |
47 | # check
48 | try:
49 | # if sestatus_return is stdout:
50 | cmd = 'sestatus'
51 | p = Popen(cmd, stdout=PIPE, stderr=PIPE, shell=True)
52 | stdout, stderr = p.communicate()
53 |
54 | if b'sestatus: not found' in stderr:
55 | reason = "SELinux is not installed."
56 | logger.info(reason)
57 | return_result = TestResult(Result.FAIL, notes=reason)
58 |
59 | elif b'disabled' in stdout:
60 | reason = "SELinux is disabled."
61 | logger.info(reason)
62 | return_result = TestResult(Result.FAIL, notes=reason)
63 |
64 | elif b'permissive' in stdout:
65 | reason = "SELinux is permissive (disabled but logging)."
66 | logger.info(reason)
67 | return_result = TestResult(Result.FAIL, notes=reason)
68 |
69 | elif b'enforcing' in stdout:
70 | reason = "SELinux is installed and enforcing."
71 | logger.info(reason)
72 | return_result = TestResult(Result.PASS)
73 |
74 | else:
75 | # wth?
76 | logger.debug("Unexpected error while looking for SELinux: "
77 | " Standard Output from sestatus command: [%s]"
78 | " Standard Error from sestatus command: [%s]",
79 | stdout, stderr)
80 | return_result = TestResult(Result.SKIP, notes="Unexpected error.")
81 |
82 | except EnvironmentError as e:
83 | # log no selinux
84 | logger.debug("Unexpected error running sestatus: [{}]".format(e))
85 | return_result = TestResult(Result.SKIP, notes="Unexpected error.")
86 |
87 | return return_result
88 |
89 |
90 | # AppArmor check - look for /etc/apparmor directory.
91 | @test_class.explanation(
92 | """
93 | Protection name: AppArmor installed
94 |
95 | Check: Run apparmor_status command and check the output
96 |
97 | Purpose: Mandatory Access Controls such as AppArmor and SELinux should be
98 | installed in order to confine applications to the leas privilege required
99 | to run them.
100 | """)
101 | @utils.linux_specific
102 | def test_apparmor():
103 | """Uses return from apparmor_status to check installation and level
104 | at which AppArmor is monitoring.
105 |
106 | :returns: A TestResult object containing the result and notes
107 | explaining why it did not pass.
108 | """
109 |
110 | # initial configurations
111 | return_result = None
112 | logger.debug("Attempting to validate AppArmor is installed.")
113 |
114 | # check
115 | try:
116 | cmd = 'apparmor_status'
117 | p = Popen(cmd, stdout=PIPE, stderr=PIPE, shell=True)
118 | stdout, stderr = p.communicate()
119 |
120 | if b'apparmor_status: command not found' in stderr:
121 | reason = "AppArmor is not installed."
122 | logger.debug(reason)
123 | return_result = TestResult(Result.FAIL, notes=reason)
124 |
125 | # enforcing check, no /'s = no directories
126 | elif b"//" not in stdout:
127 | reason = "AppArmor has no modules loaded."
128 | logger.info(reason)
129 | return_result = TestResult(Result.FAIL, notes=reason)
130 |
131 | elif b"//" in stdout:
132 | reason = "AppArmor is installed and policy is loaded."
133 | logger.info(reason)
134 | return_result = TestResult(Result.PASS)
135 | else:
136 | # wth?
137 | logger.debug("Unexpected error while looking for AppArmor: "
138 | " Standard Output from sestatus command: [%s]"
139 | " Standard Error from sestatus command: [%s]",
140 | stdout, stderr)
141 | return_result = TestResult(Result.SKIP, notes="Unexpected error.")
142 |
143 | except EnvironmentError as e:
144 | logger.debug("Unexpected error running apparmor_status: [%s]", e)
145 | return_result = TestResult(Result.SKIP, notes="Unexpected error.")
146 |
147 | return return_result
148 |
--------------------------------------------------------------------------------
/config/rbf.cfg:
--------------------------------------------------------------------------------
1 | {
2 | "paths":
3 | { "sysctl_path": "/proc/sys"
4 | },
5 |
6 | "output":
7 | { "terminal":
8 | { "term_color_end": "\\033[0;m" ,
9 | "term_color_fail": "\\033[0;31m" ,
10 | "term_color_pass": "\\033[0;32m" ,
11 | "term_color_skip": "\\033[0;33m"
12 | },
13 | "report_csv":
14 | { "csv_separator": "|"
15 | }
16 | },
17 |
18 | "html_template": "results_template.html",
19 |
20 | "modules":
21 | {
22 | "test_access": {
23 | "failed_logins_logged": null,
24 | "su_logging": null
25 | },
26 | "test_binaries": {
27 | "test_listening_files": null,
28 | "test_setuid_files": null,
29 | "test_system_critical": null
30 | },
31 | "test_cinder": {
32 | "body_size": null,
33 | "cinder_auth": null,
34 | "config_permission": null,
35 | "glance_secure": null,
36 | "keystone_secure": null,
37 | "nas_security": null,
38 | "nova_secure": null
39 | },
40 | "test_dns": {
41 | "test_default_dns_search": null,
42 | "test_dns_name": null
43 | },
44 | "test_docker": {
45 | "test_IPC_host": null,
46 | "test_cpu_priority": null,
47 | "test_docker_daemon": null,
48 | "test_docker_pid_mode": null,
49 | "test_docker_privilege": null,
50 | "test_host_network_mode": null,
51 | "test_insecure_registries": null,
52 | "test_iptables": null,
53 | "test_log_level": null,
54 | "test_memory_limit": null,
55 | "test_mount_sensitive_directories": null,
56 | "test_no_lxc": null,
57 | "test_read_only_root_fs": null,
58 | "test_restart_policy": null,
59 | "test_storage_driver": null,
60 | "test_traffic": null,
61 | "test_ulimit_default_override": null,
62 | "test_user_owned": null
63 | },
64 | "test_file_controls": {
65 | "test_perms_and_ownership": null,
66 | "test_perms_files_in_dir": null
67 | },
68 | "test_firewall": {
69 | "firewall_whitelisting": null
70 | },
71 | "test_haproxy": {
72 | "ssl_ciphers": null
73 | },
74 | "test_hardware": {
75 | "usb_authorization": null
76 | },
77 | "test_horizon": {
78 | "config_permission": null,
79 | "csrf_cookie": null,
80 | "password_autocomplete": null,
81 | "password_reveal": null,
82 | "session_cookie": null,
83 | "session_cookie_http": null
84 | },
85 | "test_kernel": {
86 | "test_kaslr": null,
87 | "test_pax": null,
88 | "test_proc_map_access": null,
89 | "test_ptrace_scope": null
90 | },
91 | "test_keystone": {
92 | "admin_token": null,
93 | "body_size": null,
94 | "config_permission": null,
95 | "token_hash": null
96 | },
97 | "test_mac": {
98 | "test_apparmor": null,
99 | "test_selinux": null
100 | },
101 | "test_manila": {
102 | "auth": null,
103 | "body_size": null,
104 | "cinder_secure": null,
105 | "config_permission": null,
106 | "keystone_secure": null,
107 | "neutron_secure": null,
108 | "nova_secure": null
109 | },
110 | "test_mem": {
111 | "test_NX": null,
112 | "test_devmem": null
113 | },
114 | "test_mounts": {
115 | "no_exec": null,
116 | "no_suid": null
117 | },
118 | "test_mysql": {
119 | "safe_config": null
120 | },
121 | "test_neutron": {
122 | "auth": null,
123 | "config_permission": null,
124 | "keystone_secure": null,
125 | "use_ssl": null
126 | },
127 | "test_nginx": {
128 | "ssl_cert": null,
129 | "ssl_ciphers": null,
130 | "ssl_protos": null,
131 | "version_advertise": null
132 | },
133 | "test_nova": {
134 | "config_permission": null,
135 | "glance_secure": null,
136 | "keystone_secure": null,
137 | "nova_auth": null
138 | },
139 | "test_package_support": {
140 | "test_supported_packages": null
141 | },
142 | "test_php": {
143 | "composer_security": null,
144 | "php_ini": null
145 | },
146 | "test_sec": {
147 | "test_certs": null,
148 | "test_shellshock": null,
149 | "test_sysctl_values": null
150 | },
151 | "test_secureboot": {
152 | "test_secureboot": null
153 | },
154 | "test_services": {
155 | "test_running_services": null,
156 | "test_service_config": null
157 | },
158 | "test_signing": {
159 | "test_module_signing": null
160 | },
161 | "test_stunnel": {
162 | "certificate_check": null,
163 | "ssl_ciphers": null,
164 | "ssl_options": null
165 | },
166 | "test_upgrades": {
167 | "missing_process_binaries": null,
168 | "reboot_required": null,
169 | "security_updates": null
170 | },
171 | "test_users": {
172 | "test_accounts_nopassword": null,
173 | "test_list_sudoers": null,
174 | "test_unique_group": null,
175 | "test_unique_user": null
176 | }
177 | }
178 | }
179 |
--------------------------------------------------------------------------------
/reconbf/modules/test_nova.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Hewlett Packard Enterprise Development LP
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | from reconbf.lib import test_class
16 | from reconbf.lib.result import GroupTestResult
17 | from reconbf.lib.result import Result
18 | from reconbf.lib.result import TestResult
19 | from reconbf.lib import utils
20 | import grp
21 | import os
22 | import pwd
23 |
24 |
25 | def _conf_location():
26 | return {'dir': '/etc/nova'}
27 |
28 |
29 | def _conf_details():
30 | config = _conf_location().copy()
31 | config['user'] = 'root'
32 | config['group'] = 'root'
33 | return config
34 |
35 |
36 | @test_class.explanation("""
37 | Protection name: Config permissions
38 |
39 | Check: Are nova config permissions ok
40 |
41 | Purpose: Nova config files contain authentication
42 | details and need to be protected. Ensure that
43 | they're only available to the service.
44 | """)
45 | @test_class.set_mapping("OpenStack:Check-Compute-01",
46 | "OpenStack:Check-Compute-02")
47 | @test_class.takes_config(_conf_details)
48 | def config_permission(config):
49 | try:
50 | user = pwd.getpwnam(config['user'])
51 | except KeyError:
52 | return TestResult(Result.SKIP,
53 | 'Could not find user "%s"' % config['user'])
54 |
55 | try:
56 | group = grp.getgrnam(config['group'])
57 | except KeyError:
58 | return TestResult(Result.SKIP,
59 | 'Could not find group "%s"' % config['group'])
60 |
61 | result = GroupTestResult()
62 | files = ['nova.conf', 'api-paste.ini', 'policy.json', 'rootwrap.conf']
63 | for f in files:
64 | path = os.path.join(config['dir'], f)
65 | result.add_result(path,
66 | utils.validate_permissions(path, 0o640, user.pw_uid,
67 | group.gr_gid))
68 | return result
69 |
70 |
71 | @test_class.explanation("""
72 | Protection name: Authentication strategy
73 |
74 | Check: Make sure proper authentication is used
75 |
76 | Purpose: There are multiple authentication backends
77 | available. Nova should be configured to authenticate
78 | against keystone rather than test backends.
79 | """)
80 | @test_class.set_mapping("OpenStack:Check-Compute-03")
81 | @test_class.takes_config(_conf_location)
82 | def nova_auth(config):
83 | try:
84 | path = os.path.join(config['dir'], 'nova.conf')
85 | nova_conf = utils.parse_openstack_ini(path)
86 | except EnvironmentError:
87 | return TestResult(Result.SKIP, 'cannot read nova config files')
88 |
89 | auth = nova_conf.get('DEFAULT', {}).get('auth_strategy', 'keystone')
90 | if auth != 'keystone':
91 | return TestResult(Result.FAIL,
92 | 'authentication should be done by keystone')
93 | else:
94 | return TestResult(Result.PASS)
95 |
96 |
97 | @test_class.explanation("""
98 | Protection name: Keystone api access
99 |
100 | Check: Does Keystone access use secure connection
101 |
102 | Purpose: OpenStack components communicate with each other
103 | using various protocols and the communication might
104 | involve sensitive / confidential data. An attacker may
105 | try to eavesdrop on the channel in order to get access to
106 | sensitive information. Thus all the components must
107 | communicate with each other using a secured communication
108 | protocol.
109 | """)
110 | @test_class.set_mapping("OpenStack:Check-Compute-04")
111 | @test_class.takes_config(_conf_location)
112 | def keystone_secure(config):
113 | try:
114 | path = os.path.join(config['dir'], 'nova.conf')
115 | nova_conf = utils.parse_openstack_ini(path)
116 | except EnvironmentError:
117 | return TestResult(Result.SKIP, 'cannot read nova config files')
118 |
119 | protocol = nova_conf.get('keystone_authtoken', {}).get('auth_protocol',
120 | 'https')
121 | identity = nova_conf.get('keystone_authtoken', {}).get('identity_uri',
122 | 'https:')
123 |
124 | if not identity.startswith('https:'):
125 | return TestResult(Result.FAIL, 'keystone access is not secure')
126 | if protocol != 'https':
127 | return TestResult(Result.FAIL, 'keystone access is not secure')
128 |
129 | return TestResult(Result.PASS)
130 |
131 |
132 | @test_class.explanation("""
133 | Protection name: Glance api access
134 |
135 | Check: Does Glance access use secure connection
136 |
137 | Purpose: OpenStack components communicate with each other
138 | using various protocols and the communication might
139 | involve sensitive / confidential data. An attacker may
140 | try to eavesdrop on the channel in order to get access to
141 | sensitive information. Thus all the components must
142 | communicate with each other using a secured communication
143 | protocol.
144 | """)
145 | @test_class.set_mapping("OpenStack:Check-Compute-05")
146 | @test_class.takes_config(_conf_location)
147 | def glance_secure(config):
148 | try:
149 | path = os.path.join(config['dir'], 'nova.conf')
150 | nova_conf = utils.parse_openstack_ini(path)
151 | except EnvironmentError:
152 | return TestResult(Result.SKIP, 'cannot read nova config files')
153 |
154 | insecure = nova_conf.get('glance', {}).get(
155 | 'api_insecure', 'False').lower() == 'true'
156 |
157 | if insecure:
158 | return TestResult(Result.FAIL, 'glance access is not secure')
159 | else:
160 | return TestResult(Result.PASS)
161 |
--------------------------------------------------------------------------------
/reconbf/modules/test_keystone.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Hewlett Packard Enterprise Development LP
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | from reconbf.lib import test_class
16 | from reconbf.lib.result import GroupTestResult
17 | from reconbf.lib.result import Result
18 | from reconbf.lib.result import TestResult
19 | from reconbf.lib import utils
20 | import grp
21 | import os
22 | import pwd
23 |
24 |
25 | def _conf_location():
26 | return {'dir': '/etc/keystone'}
27 |
28 |
29 | @test_class.explanation("""
30 | Protection name: No admin token
31 |
32 | Check: Ensure no admin token is configured for keystone
33 | authentication.
34 |
35 | Purpose: Admin token should only be used for initial
36 | configuration. Once the system is running, the token
37 | should be removed and only runtime configuration used.
38 | """)
39 | @test_class.set_mapping("OpenStack:Check-Identity-06")
40 | @test_class.takes_config(_conf_location)
41 | def admin_token(config):
42 | try:
43 | path = os.path.join(config['dir'], 'keystone.conf')
44 | keystone_ini = utils.parse_openstack_ini(path)
45 | path = os.path.join(config['dir'], 'keystone-paste.ini')
46 | paste_ini = utils.parse_openstack_ini(path)
47 | except EnvironmentError:
48 | return TestResult(Result.SKIP, 'cannot read keystone config files')
49 |
50 | keystone_req = {
51 | "DEFAULT.admin_token": {"disallowed": "*"},
52 | }
53 | keystone_res = utils.verify_config("keystone.conf", keystone_ini,
54 | keystone_req, needs_parsing=False)
55 |
56 | paste_req = {
57 | "filter:admin_token_auth.AdminTokenAuthMiddleware": {"disallowed": "*"}
58 | }
59 | paste_res = utils.verify_config("keystone-paste.ini", paste_ini, paste_req,
60 | needs_parsing=False)
61 |
62 | result = GroupTestResult()
63 | for res in keystone_res:
64 | result.add_result(res[0], res[1])
65 | for res in paste_res:
66 | result.add_result(res[0], res[1])
67 | return result
68 |
69 |
70 | @test_class.explanation("""
71 | Protection name: Body size limit
72 |
73 | Check: Ensure large requests are stopped.
74 |
75 | Purpose: Large requests can cause a denial of service.
76 | Setting up a limit ensures that they're rejected without
77 | full processing.
78 | """)
79 | @test_class.set_mapping("OpenStack:Check-Identity-05")
80 | @test_class.takes_config(_conf_location)
81 | def body_size(config):
82 | try:
83 | path = os.path.join(config['dir'], 'keystone.conf')
84 | keystone_ini = utils.parse_openstack_ini(path)
85 | except EnvironmentError:
86 | return TestResult(Result.SKIP, 'cannot read keystone config files')
87 |
88 | keystone_req = {
89 | "DEFAULT.max_request_body_size": {"allowed": "*"},
90 | }
91 | keystone_res = utils.verify_config("keystone.conf", keystone_ini,
92 | keystone_req, needs_parsing=False)
93 |
94 | result = GroupTestResult()
95 | for res in keystone_res:
96 | result.add_result(res[0], res[1])
97 | return result
98 |
99 |
100 | @test_class.explanation("""
101 | Protection name: Token hash algorithm
102 |
103 | Check: Verify whether pki tokens are used with weak
104 | hashes.
105 |
106 | Purpose: If the token provider is either pki or pkiz
107 | make sure that a strong hash is used, preventing
108 | spoofing of credentials.
109 | """)
110 | @test_class.set_mapping("OpenStack:Check-Identity-04")
111 | @test_class.takes_config(_conf_location)
112 | def token_hash(config):
113 | try:
114 | path = os.path.join(config['dir'], 'keystone.conf')
115 | keystone_ini = utils.parse_openstack_ini(path)
116 | except EnvironmentError:
117 | return TestResult(Result.SKIP, 'cannot read keystone config files')
118 |
119 | provider = keystone_ini.get('token', {}).get('provider', 'uuid')
120 | if (provider.startswith('keystone.token.providers.') and
121 | provider.endswith('.Provider')):
122 | provider = provider[25:-9]
123 |
124 | if provider not in ('pki', 'pkiz'):
125 | return TestResult(Result.SKIP, 'test relevant only for pki tokens')
126 |
127 | single = keystone_ini.get('token', {}).get('hash_algorithm')
128 | plural = keystone_ini.get('token', {}).get('hash_algorithms')
129 | val = plural or single
130 |
131 | if val is None or val.lower() not in ('sha256', 'sha512'):
132 | return TestResult(Result.FAIL, 'token hash should be sha256 or sha512')
133 |
134 | return TestResult(Result.PASS)
135 |
136 |
137 | def _conf_details():
138 | config = _conf_location().copy()
139 | config['user'] = 'keystone'
140 | config['group'] = 'keystone'
141 | return config
142 |
143 |
144 | @test_class.explanation("""
145 | Protection name: Config permissions
146 |
147 | Check: Are keystone config permissions ok
148 |
149 | Purpose: Keystone config files are critical to the
150 | system's authentication. Ensure that they're only
151 | available to the service.
152 | """)
153 | @test_class.set_mapping("OpenStack:Check-Identity-01",
154 | "OpenStack:Check-Identity-02")
155 | @test_class.takes_config(_conf_details)
156 | def config_permission(config):
157 | try:
158 | user = pwd.getpwnam(config['user'])
159 | except KeyError:
160 | return TestResult(Result.SKIP,
161 | 'Could not find user "%s"' % config['user'])
162 |
163 | try:
164 | group = grp.getgrnam(config['group'])
165 | except KeyError:
166 | return TestResult(Result.SKIP,
167 | 'Could not find group "%s"' % config['group'])
168 |
169 | result = GroupTestResult()
170 | files = ['keystone.conf',
171 | 'keystone-paste.ini',
172 | 'policy.json',
173 | 'logging.conf',
174 | 'ssl/certs/signing_cert.pem',
175 | 'ssl/private/signing_key.pem',
176 | 'ssl/certs/ca.pem',
177 | ]
178 | for f in files:
179 | path = os.path.join(config['dir'], f)
180 | result.add_result(path,
181 | utils.validate_permissions(path, 0o640, user.pw_uid,
182 | group.gr_gid))
183 | return result
184 |
--------------------------------------------------------------------------------
/reconbf/modules/test_haproxy.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Hewlett Packard Enterprise Development LP
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | from reconbf.lib import test_class
16 | from reconbf.lib import utils
17 | from reconbf.lib.logger import logger
18 | from reconbf.lib.result import GroupTestResult
19 | from reconbf.lib.result import Result
20 | from reconbf.lib.result import TestResult
21 |
22 | import collections
23 |
24 |
25 | HAPROXY_CONFIG_PATH = '/etc/haproxy/haproxy.cfg'
26 | REPEATING_OPTIONS = ['bind', 'option', 'errorfile']
27 |
28 |
29 | class ParsingError(Exception):
30 | pass
31 |
32 |
33 | def _read_config(path):
34 | with open(path, 'r') as f:
35 | conf_lines = f.readlines()
36 |
37 | config = collections.defaultdict(dict)
38 | section = None
39 |
40 | for lineno, line in enumerate(conf_lines):
41 | # strip comment
42 | try:
43 | comment_start = line.index('#')
44 | except ValueError:
45 | pass # no comment found
46 | else:
47 | line = line[:comment_start]
48 |
49 | line = line.strip()
50 | if not line:
51 | continue
52 |
53 | parts = [p.strip() for p in line.split(' ')]
54 | if parts[0] in ('global', 'defaults'):
55 | section = parts[0]
56 | continue
57 | elif parts[0] in ('listen', 'frontend', 'backend'):
58 | if len(line) == 1:
59 | logger.warning("section '%s' is missing a name, ignoring line",
60 | parts[0])
61 | continue
62 | else:
63 | section = '/'.join(parts)
64 | continue
65 |
66 | if section is None:
67 | logger.warning("option outside of any section, ignoring")
68 | continue
69 |
70 | key = parts[0]
71 | if len(parts) == 1:
72 | val = None
73 | else:
74 | val = parts[1:]
75 |
76 | if key in REPEATING_OPTIONS:
77 | if key not in config[section]:
78 | config[section][key] = []
79 | config[section][key].append(val)
80 | else:
81 | config[section][key] = val
82 |
83 | return config
84 |
85 |
86 | def _conf_bad_ciphers():
87 | return {
88 | 'configs': '/etc/haproxy/haproxy.cfg',
89 | 'bad_ciphers': ['DES', 'MD5', 'RC4', 'SEED', 'aNULL', 'eNULL'],
90 | }
91 |
92 |
93 | @test_class.takes_config(_conf_bad_ciphers)
94 | @test_class.explanation("""
95 | Protection name: Forbid known broken and weak protocols
96 |
97 | Check: Make sure that neither the default configuration nor
98 | any of the server sections allows ciphers which are known
99 | to be weak or broken.
100 |
101 | Purpose: OpenSSL comes with ciphers which should not be used
102 | in production. For example MD5 and RC4 algorithms have known
103 | issues when applied in SSL/TLS context. This check will list
104 | all available OpenSSL ciphers and make sure that the configured
105 | ciphers are not allowed.
106 |
107 | For information about a secure string, see
108 | https://hynek.me/articles/hardening-your-web-servers-ssl-ciphers/
109 | """)
110 | def ssl_ciphers(test_config):
111 | try:
112 | config = _read_config(test_config['configs'])
113 | except IOError:
114 | return TestResult(Result.SKIP, "haproxy config not found")
115 |
116 | bad_ciphers_desc = ':'.join(test_config['bad_ciphers'])
117 | try:
118 | bad_ciphers = set(utils.expand_openssl_ciphers(bad_ciphers_desc))
119 | except Exception:
120 | return TestResult(Result.SKIP,
121 | "Cannot use openssl to expand cipher list")
122 | results = GroupTestResult()
123 |
124 | # no need to check the options if haproxy doesn't handle ssl traffic
125 | frontends_with_ssl = set()
126 | backends_with_ssl = set()
127 | for section in config:
128 | if section.startswith('frontend') or section.startswith('listen'):
129 | for bind in config[section].get('bind', []):
130 | if 'ssl' in bind:
131 | frontends_with_ssl.add(section)
132 | if section.startswith('backend') or section.startswith('listen'):
133 | for server in config[section].get('server', []):
134 | if 'check-ssl' in server:
135 | backends_with_ssl.add(section)
136 | if 'ssl' in server:
137 | backends_with_ssl.add(section)
138 |
139 | if not frontends_with_ssl and not backends_with_ssl:
140 | return TestResult(Result.SKIP, "no section enables ssl")
141 |
142 | # there are two defaults - for incoming and outgoing connections
143 | default_bind_ciphers_desc = config['global'].get(
144 | 'ssl-default-bind-ciphers', ['DEFAULT'])[0]
145 | default_bind_ciphers = utils.expand_openssl_ciphers(
146 | default_bind_ciphers_desc)
147 | default_server_ciphers_desc = config['global'].get(
148 | 'ssl-default-server-ciphers', ['DEFAULT'])[0]
149 | default_server_ciphers = utils.expand_openssl_ciphers(
150 | default_server_ciphers_desc)
151 |
152 | if frontends_with_ssl:
153 | failures = ','.join(set(default_bind_ciphers) & bad_ciphers)
154 | test_name = "default-bind-ciphers"
155 | if failures:
156 | msg = "forbidden ciphers: %s" % failures
157 | results.add_result(test_name, TestResult(Result.FAIL, msg))
158 | else:
159 | results.add_result(test_name, TestResult(Result.PASS))
160 |
161 | if backends_with_ssl:
162 | failures = ','.join(set(default_server_ciphers) & bad_ciphers)
163 | test_name = "default-server-ciphers"
164 | if failures:
165 | msg = "forbidden ciphers: %s" % failures
166 | results.add_result(test_name, TestResult(Result.FAIL, msg))
167 | else:
168 | results.add_result(test_name, TestResult(Result.PASS))
169 |
170 | for section in config:
171 | # don't check anything that doesn't enable ssl
172 | if section not in (backends_with_ssl | frontends_with_ssl):
173 | continue
174 |
175 | section_ciphers_desc = config[section].get('ciphers', [None])[0]
176 | if section_ciphers_desc:
177 | section_ciphers = utils.expand_openssl_ciphers(
178 | section_ciphers_desc)
179 | else:
180 | if section.startswith('backend'):
181 | section_ciphers = default_server_ciphers
182 | elif section.startswith('frontend'):
183 | section_ciphers = default_bind_ciphers
184 | elif section.startswith('listen'):
185 | section_ciphers = default_bind_ciphers
186 |
187 | failures = ','.join(set(section_ciphers) & bad_ciphers)
188 | if failures:
189 | msg = "forbidden ciphers: %s" % failures
190 | results.add_result(section, TestResult(Result.FAIL, msg))
191 | else:
192 | results.add_result(section, TestResult(Result.PASS))
193 |
194 | return results
195 |
--------------------------------------------------------------------------------
/config/hos.cfg:
--------------------------------------------------------------------------------
1 | {
2 | "paths":
3 | { "sysctl_path": "/proc/sys"
4 | },
5 |
6 | "output":
7 | { "terminal":
8 | { "term_color_end": "\\033[0;m" ,
9 | "term_color_fail": "\\033[0;31m" ,
10 | "term_color_pass": "\\033[0;32m" ,
11 | "term_color_skip": "\\033[0;33m"
12 | },
13 | "report_csv":
14 | { "csv_separator": "|"
15 | }
16 | },
17 |
18 | "html_template": "results_template.html",
19 |
20 | "modules": {
21 | "test_file_controls": {
22 | "test_perms_and_ownership": [
23 |
24 | { "file" : "/etc/inittab",
25 | "disallowed_perms" : "x,rwx,rwx" },
26 |
27 | { "file" : "/etc/security/console.perms",
28 | "disallowed_perms" : "x,rwx,rwx" },
29 |
30 | { "file" : "/etc/sysctl.conf",
31 | "disallowed_perms" : "x,wx,wx",
32 | "owner": "root",
33 | "group": "root" },
34 |
35 | { "file" : "/etc/bash.bashrc",
36 | "disallowed_perms" : "x,wx,wx",
37 | "owner": "root",
38 | "group": "root" },
39 |
40 | { "file" : "/etc/securetty",
41 | "disallowed_perms" : "x,rwx,rwx",
42 | "owner": "root",
43 | "group": "root" },
44 |
45 | { "file" : "/etc/sudoers",
46 | "disallowed_perms" : "wx,wx,rwx" },
47 |
48 | { "file" : "/etc/rsyslog.conf",
49 | "disallowed_perms" : "x,wx,rwx",
50 | "owner": "root",
51 | "group": "root" },
52 |
53 | { "file" : "/etc/crontab",
54 | "disallowed_perms" : "x,rwx,rwx",
55 | "owner": "root",
56 | "group": "root" },
57 |
58 | { "file" : "/etc/fstab",
59 | "disallowed_perms" : "x,wx,rwx",
60 | "owner": "root",
61 | "group": "root" },
62 |
63 | { "file" : "/etc/dpkg",
64 | "disallowed_perms" : ",w,rwx",
65 | "owner": "root",
66 | "group": "root" },
67 |
68 | { "file" : "/etc/security/access.conf",
69 | "disallowed_perms" : "x,wx,rwx",
70 | "owner": "root",
71 | "group": "root" },
72 |
73 | { "file" : "/etc/shadow",
74 | "disallowed_perms" : "x,wx,rwx",
75 | "owner": "root" },
76 |
77 | { "file" : "/etc/passwd",
78 | "disallowed_perms" : "x,wx,wx" },
79 |
80 | { "file" : "/etc/group",
81 | "disallowed_perms" : "x,wx,wx" },
82 |
83 | { "file" : "/root",
84 | "disallowed_perms" : ",rwx,rwx" },
85 |
86 | { "file" : "/var/log/auth.log",
87 | "disallowed_perms" : "x,wx,rwx" },
88 |
89 | { "file" : "/var/log/dmesg",
90 | "disallowed_perms" : "x,wx,rwx" },
91 |
92 | { "file" : "/var/log/wtmp",
93 | "disallowed_perms" : "x,rwx,rwx" },
94 |
95 | { "file" : "/var/log/lastlog",
96 | "disallowed_perms" : "x,rwx,rwx" },
97 |
98 | { "file" : "/var/spool/cron",
99 | "disallowed_perms" : "x,rwx,rwx" },
100 |
101 | { "file" : "/var/spool/cron/root",
102 | "disallowed_perms" : "x,rwx,rwx" },
103 |
104 | { "file" : "/etc/gshadow",
105 | "disallowed_perms" : "x,wx,rwx",
106 | "owner": "root" },
107 |
108 | { "file" : "/boot/grub/grub.cfg",
109 | "disallowed_perms" : "x,wx,wx" },
110 |
111 | { "file" : "/lib",
112 | "disallowed_perms" : ",w,w" },
113 |
114 | { "file" : "/lib64",
115 | "disallowed_perms" : ",w,w" },
116 |
117 | { "file" : "/usr/lib",
118 | "disallowed_perms" : ",w,w" },
119 |
120 | { "file" : "/usr/lib64",
121 | "disallowed_perms" : ",w,w" },
122 |
123 | { "file" : "/etc/rc0.d",
124 | "disallowed_perms" : ",w,w",
125 | "owner" : "root",
126 | "group" : "root" },
127 |
128 | { "file" : "/etc/rc1.d",
129 | "disallowed_perms" : ",w,w",
130 | "owner" : "root",
131 | "group" : "root" },
132 |
133 | { "file" : "/etc/rc2.d",
134 | "disallowed_perms" : ",w,w",
135 | "owner" : "root",
136 | "group" : "root" },
137 |
138 | { "file" : "/etc/rc3.d",
139 | "disallowed_perms" : ",w,w",
140 | "owner" : "root",
141 | "group" : "root" },
142 |
143 | { "file" : "/etc/rc4.d",
144 | "disallowed_perms" : ",w,w",
145 | "owner" : "root",
146 | "group" : "root" },
147 |
148 | { "file" : "/etc/rc5.d",
149 | "disallowed_perms" : ",w,w",
150 | "owner" : "root",
151 | "group" : "root" },
152 |
153 | { "file" : "/etc/rc6.d",
154 | "disallowed_perms" : ",w,w",
155 | "owner" : "root",
156 | "group" : "root" }
157 |
158 | ],
159 |
160 | "test_perms_files_in_dir": [
161 |
162 | { "directory" : "/root",
163 | "dir_disallowed_perms" : ",,rwx",
164 | "file_disallowed_perms" : ",,rwx",
165 | "owner": "root" },
166 |
167 | { "directory" : "/usr/sbin",
168 | "dir_disallowed_perms" : ",,w",
169 | "file_disallowed_perms" : ",,w" },
170 |
171 | { "directory" : "/etc/init.d",
172 | "file_disallowed_perms" : ",,w" }
173 |
174 | ]
175 | },
176 | "test_sec": {
177 | "test_sysctl_values": [
178 |
179 | { "name" : "TCP Syncookie protection",
180 | "key" : "net/ipv4/tcp_syncookies",
181 | "allowed_values" : "1" },
182 |
183 | { "key" : "net/ipv4/tcp_max_syn_backlog",
184 | "allowed_values" : "4096" },
185 |
186 | { "key" : "net/ipv4/conf/all/rp_filter",
187 | "allowed_values" : "1" },
188 |
189 | { "key" : "net/ipv4/conf/all/accept_source_route",
190 | "allowed_values" : "0" },
191 |
192 | { "key" : "net/ipv4/conf/all/accept_redirects",
193 | "allowed_values" : "0" },
194 |
195 | { "key" : "net/ipv4/conf/all/secure_redirects",
196 | "allowed_values" : "0" },
197 |
198 | { "key" : "net/ipv4/conf/default/accept_redirects",
199 | "allowed_values" : "0" },
200 |
201 | { "key" : "net/ipv4/conf/default/secure_redirects",
202 | "allowed_values" : "0" },
203 |
204 | { "key" : "net/ipv4/conf/all/send_redirects",
205 | "allowed_values" : "0" },
206 |
207 | { "key" : "net/ipv4/conf/default/send_redirects",
208 | "allowed_values" : "0" },
209 |
210 | { "key" : "net/ipv4/icmp_echo_ignore_broadcasts",
211 | "allowed_values" : "1" },
212 |
213 | { "key" : "net/ipv4/icmp_ignore_bogus_error_responses",
214 | "allowed_values" : "1" },
215 |
216 | { "key" : "net/ipv4/ip_forward",
217 | "allowed_values" : "0" },
218 |
219 | { "key" : "net/ipv4/conf/all/log_martians",
220 | "allowed_values" : "1" },
221 |
222 | { "key" : "net/ipv4/conf/default/rp_filter",
223 | "allowed_values" : "1" },
224 |
225 | { "key" : "vm/swappiness",
226 | "allowed_values" : "0" },
227 |
228 | { "key" : "vm/mmap_min_addr",
229 | "allowed_values" : "4096, 8192, 16384, 32768, 65536, 131072" },
230 |
231 | { "key" : "kernel/core_pattern",
232 | "allowed_values" : "core" },
233 |
234 | { "key" : "kernel/randomize_va_space",
235 | "allowed_values" : "2" },
236 |
237 | { "key" : "kernel/exec-shield",
238 | "allowed_values" : "1" }
239 |
240 | ],
241 |
242 | "test_shellshock": {
243 | "exploit_command": "env X='() { :;}; echo vulnerable' bash -c 'echo this is a test'"
244 | }
245 | },
246 | "test_binaries": {
247 | "test_setuid_files": {
248 | "relro": "full",
249 | "stack_canary": true,
250 | "nx": true,
251 | "pie": true,
252 | "runpath": false,
253 | "fortify": true
254 | },
255 | "test_system_critical": {
256 | "policy" : {
257 | "relro": "full",
258 | "stack_canary": true,
259 | "nx": true,
260 | "pie": true,
261 | "runpath": false,
262 | "fortify": true
263 | },
264 | "paths": [
265 | "/usr/sbin/httpd", "/usr/sbin/sshd"
266 | ]
267 | }
268 | },
269 |
270 | "test_services": {
271 | "test_running_services": [
272 | { "name" : "Running firewall service",
273 | "services" : [ "iptables", "ufw" ],
274 | "expected" : "on",
275 | "match" : "one",
276 | "fail" : "True" },
277 |
278 | { "name" : "Not running unencrypted services",
279 | "services" : [ "ftp", "telnetd", "vsftpd" ],
280 | "expected" : "off",
281 | "match" : "all",
282 | "fail" : "True" },
283 |
284 | { "name" : "Not running legacy remote utilities",
285 | "services" : [ "rsh", "rlogin", "rexec", "rcp" ],
286 | "expected" : "off",
287 | "match" : "all",
288 | "fail" : "True" },
289 |
290 | { "name" : "Not running mail servers",
291 | "services" : [ "sendmail", "exim", "postfix", "qmail" ],
292 | "expected" : "off",
293 | "match" : "all",
294 | "fail" : "True" }
295 | ],
296 |
297 | "test_service_config": [
298 | {
299 | "name" : "Secure SSH config",
300 | "config" : "/etc/ssh/sshd_config",
301 | "Protocol" : { "allowed": ["2"] },
302 | "PasswordAuthentication" : { "allowed": ["no"] },
303 | "PermitRootLogin": { "allowed": ["no"] },
304 | "ChallengeResponseAuthentication": { "disallowed": ["yes"]}
305 | }
306 | ]
307 | }
308 | }
309 | }
310 |
--------------------------------------------------------------------------------
/reconbf/__main__.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Hewlett Packard Enterprise Development LP
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | # ReconBF main module and test runner
16 | from .lib.logger import logger
17 | from .lib.test_class import TestSet
18 | from .lib import config
19 | from .lib.result import ResultDisplayType
20 |
21 | import argparse
22 | import json
23 | import logging
24 | import os
25 | import sys
26 |
27 |
28 | def main():
29 | args = _parse_args()
30 |
31 | logger.setLevel(_log_level_from_arg(args.level))
32 |
33 | # are we just writing configuration instead of doing standard run?
34 | if args.generate_config:
35 | if args.config_file:
36 | fobj = open(args.config_file, "w")
37 | else:
38 | fobj = sys.stdout
39 | _write_generated_config(fobj, args.generate_config)
40 | fobj.flush()
41 | sys.exit()
42 |
43 | # are we just explaining a specifc test?
44 | if args.explain:
45 | test_set = TestSet()
46 | test_set.add_known_tests()
47 | for test in test_set.tests:
48 | test_name = test['module'] + '.' + test['name']
49 | if test_name == args.explain:
50 | print("Test:")
51 | print(" " + test_name)
52 | print("")
53 | print("Explanation:")
54 | print(test['function'].explanation)
55 | sys.exit()
56 |
57 | print("Test not found")
58 | sys.exit(1)
59 |
60 | _check_root()
61 |
62 | # prefer: 1) cmd line config file 2) default
63 | config_path = args.config_file or 'config/rbf.cfg'
64 | try:
65 | with open(config_path, 'r') as config_file:
66 | config.config = config.Config(config_file)
67 | except EnvironmentError:
68 | logger.error("Unable to open config file [ %s ]", config_path)
69 | sys.exit(2)
70 |
71 | test_set = TestSet()
72 | added = test_set.add_known_tests(config.get_configured_tests())
73 | logger.info("Loaded [ %s ] tests", added)
74 |
75 | results = test_set.run()
76 | display_mode = _get_display_type(args.display_mode)
77 | results.display_on_terminal(use_color=True,
78 | display_type=display_mode)
79 |
80 | # If a report was selected, generate it
81 | if args.report_type is not 'none':
82 | _output_report(results, args.report_type, args.report_file,
83 | display_mode=display_mode)
84 |
85 | if results.had_failures:
86 | sys.exit(1)
87 | else:
88 | sys.exit(0)
89 |
90 |
91 | def _generate_config(mode):
92 | new_config = {'modules': {}}
93 | modules_config = new_config['modules']
94 |
95 | test_set = TestSet()
96 | test_set.add_known_tests()
97 | for test in test_set.tests:
98 | test_mod = test['module']
99 |
100 | # insert module if missing
101 | if test_mod not in modules_config:
102 | modules_config[test_mod] = {}
103 |
104 | takes_config = hasattr(test['function'], "takes_config")
105 | if mode == 'default' or not takes_config:
106 | modules_config[test_mod][test['name']] = None
107 | else:
108 | test_config = test['function'].config_generator()
109 | modules_config[test_mod][test['name']] = test_config
110 |
111 | return new_config
112 |
113 |
114 | def _write_generated_config(output, mode):
115 | new_config = _generate_config(mode)
116 |
117 | config_content = json.dumps(new_config, separators=(',', ': '),
118 | indent=4, sort_keys=True)
119 | if str is bytes:
120 | config_content = config_content.decode('utf-8')
121 |
122 | output.write(config_content)
123 |
124 |
125 | def _check_root():
126 | """Check for root, throw error and exit if not
127 |
128 | :return: -
129 | """
130 | if os.getuid() != 0:
131 | logger.error("RBF must be run as root!")
132 | sys.exit(2)
133 |
134 |
135 | def _get_display_type(display_mode):
136 | return_val = None
137 | if display_mode == 'all':
138 | return_val = ResultDisplayType.DISPLAY_ALL
139 | elif display_mode == 'fail':
140 | return_val = ResultDisplayType.DISPLAY_FAIL_ONLY
141 | elif display_mode == 'overall':
142 | return_val = ResultDisplayType.DISPLAY_OVERALL_ONLY
143 | elif display_mode == 'notpass':
144 | return_val = ResultDisplayType.DISPLAY_NOT_PASS
145 | return return_val
146 |
147 |
148 | def _log_level_from_arg(specified_level):
149 | """Change user supplied log level string to logging level
150 |
151 | :param specified_level: User supplied string
152 | :return: equivalent logging level
153 | """
154 | # default is INFO
155 | log_level = logging.INFO
156 | if specified_level == 'error':
157 | log_level = logging.ERROR
158 | elif specified_level == 'debug':
159 | log_level = logging.DEBUG
160 | return log_level
161 |
162 |
163 | def _output_report(results, report_type, report_file, display_mode=None):
164 | if report_type == 'csv':
165 | results.write_csv(report_file)
166 | elif report_type == 'json':
167 | results.write_json(report_file)
168 | elif report_type == 'html':
169 | try:
170 | html_template = config.get_config('html_template')
171 | except config.ConfigNotFound:
172 | logger.error("Unable to find 'html_template' setting in config")
173 | sys.exit(2)
174 | else:
175 | templates_dir = 'reconbf/templates'
176 |
177 | html_template = templates_dir + '/' + html_template
178 | logger.info("Using template from %s", html_template)
179 | results.write_html(report_file, html_template, display_mode)
180 |
181 |
182 | def _parse_args():
183 | """Parse command line args
184 |
185 | :return: Selected args
186 | """
187 | parser = argparse.ArgumentParser(
188 | description='ReconBF - a Python OS security feature tester')
189 |
190 | parser.add_argument('-c', '--config', dest='config_file', action='store',
191 | default=None, type=str, help='use specified config '
192 | 'file instead of default')
193 |
194 | parser.add_argument('-g', '--generate', dest='generate_config',
195 | action='store',
196 | choices=['default', 'inline'],
197 | default=None, type=str,
198 | help="generates config file contetns with all the "
199 | "available modules listed and either configured "
200 | "to use the config that comes with the test, or "
201 | "inlines the current default configuration")
202 |
203 | parser.add_argument('-l' '--level', dest='level', action='store',
204 | choices=['debug', 'info', 'error'], default='info',
205 | type=str, help='log level: can be "debug", "info", '
206 | 'or "error" default=info')
207 |
208 | parser.add_argument('-rf', '--reportfile', dest='report_file',
209 | action='store', default='result.out', type=str,
210 | help='output file: default=result.out')
211 |
212 | parser.add_argument('-rt', '--reporttype', dest='report_type',
213 | action='store', choices=['csv', 'json', 'html'],
214 | default='none', type=str,
215 | help='output type: can be "csv", "json", or "html"')
216 |
217 | parser.add_argument('-dm', '--displaymode', dest='display_mode',
218 | action='store',
219 | choices=['all', 'fail', 'overall', 'notpass'],
220 | default='notpass', type=str,
221 | help="controls how tests are displayed: all-displays "
222 | "all results, fail-displays only tests which "
223 | "failed, overall-displays parent test statuses "
224 | "only, notpass-displays any test which didn't "
225 | "pass")
226 |
227 | parser.add_argument('-e', '--explain', action='store', default=None,
228 | metavar='TEST_NAME', type=str,
229 | help="explain what does a specific test "
230 | "module do and why")
231 |
232 | return parser.parse_args()
233 |
234 |
235 | if __name__ == "__main__":
236 | main()
237 |
--------------------------------------------------------------------------------
/tests/test_cinder.py:
--------------------------------------------------------------------------------
1 | from reconbf.modules import test_cinder
2 | from reconbf.lib.result import Result, TestResult
3 | from reconbf.lib import utils
4 |
5 | import pwd
6 | import grp
7 | import unittest
8 | from mock import patch
9 |
10 |
11 | class ConfigPermissions(unittest.TestCase):
12 | conf = {'dir': '', 'user': '', 'group': ''}
13 | pwd_root = pwd.struct_passwd(('root', 'x', 0, 0, 'root', '/root',
14 | '/bin/bash'))
15 | grp_root = grp.struct_group(('root', 'x', 0, []))
16 |
17 | def test_no_user(self):
18 | with patch.object(pwd, 'getpwnam', side_effect=KeyError()):
19 | with patch.object(grp, 'getgrnam', return_value=self.grp_root):
20 | res = test_cinder.config_permission(self.conf)
21 | self.assertEqual(res.result, Result.SKIP)
22 |
23 | def test_no_group(self):
24 | with patch.object(pwd, 'getpwnam', return_value=self.pwd_root):
25 | with patch.object(grp, 'getgrnam', side_effect=KeyError()):
26 | res = test_cinder.config_permission(self.conf)
27 | self.assertEqual(res.result, Result.SKIP)
28 |
29 | def test_good_perm(self):
30 | with patch.object(pwd, 'getpwnam', return_value=self.pwd_root):
31 | with patch.object(grp, 'getgrnam', return_value=self.grp_root):
32 | with patch.object(utils, 'validate_permissions',
33 | return_value=TestResult(Result.PASS)):
34 | res = test_cinder.config_permission(self.conf)
35 | self.assertEqual(res.result, Result.PASS)
36 |
37 | def test_bad_perm(self):
38 | with patch.object(pwd, 'getpwnam', return_value=self.pwd_root):
39 | with patch.object(grp, 'getgrnam', return_value=self.grp_root):
40 | with patch.object(utils, 'validate_permissions',
41 | return_value=TestResult(Result.FAIL)):
42 | res = test_cinder.config_permission(self.conf)
43 | self.assertEqual(res.result, Result.FAIL)
44 |
45 |
46 | class CinderAuth(unittest.TestCase):
47 | conf = {'dir': ''}
48 |
49 | def test_no_config(self):
50 | with patch.object(utils, 'parse_openstack_ini',
51 | side_effect=EnvironmentError()):
52 | res = test_cinder.cinder_auth(self.conf)
53 | self.assertEqual(res.result, Result.SKIP)
54 |
55 | def _run_with_config(self, os_ini):
56 | with patch.object(utils, 'parse_openstack_ini', return_value=os_ini):
57 | return test_cinder.cinder_auth(self.conf)
58 |
59 | def test_keystone(self):
60 | res = self._run_with_config({'DEFAULT': {'auth_strategy': 'keystone'}})
61 | self.assertEqual(res.result, Result.PASS)
62 |
63 | def test_other(self):
64 | res = self._run_with_config({'DEFAULT': {'auth_strategy': 'other'}})
65 | self.assertEqual(res.result, Result.FAIL)
66 |
67 |
68 | class KeystoneSecure(unittest.TestCase):
69 | conf = {'dir': ''}
70 |
71 | def test_no_config(self):
72 | with patch.object(utils, 'parse_openstack_ini',
73 | side_effect=EnvironmentError()):
74 | res = test_cinder.keystone_secure(self.conf)
75 | self.assertEqual(res.result, Result.SKIP)
76 |
77 | def _run_with_config(self, os_ini):
78 | with patch.object(utils, 'parse_openstack_ini', return_value=os_ini):
79 | res = test_cinder.keystone_secure(self.conf)
80 | return res
81 |
82 | def test_bad_proto(self):
83 | res = self._run_with_config({'keystone_authtoken': {
84 | 'auth_protocol': 'http',
85 | 'identity_uri': 'https://abc'}})
86 | self.assertEqual(res.result, Result.FAIL)
87 |
88 | def test_bad_uri(self):
89 | res = self._run_with_config({'keystone_authtoken': {
90 | 'auth_protocol': 'https',
91 | 'identity_uri': 'http://abc'}})
92 | self.assertEqual(res.result, Result.FAIL)
93 |
94 | def test_ok(self):
95 | res = self._run_with_config({'keystone_authtoken': {
96 | 'auth_protocol': 'https',
97 | 'identity_uri': 'https://abc'}})
98 | self.assertEqual(res.result, Result.PASS)
99 |
100 |
101 | class NovaSecure(unittest.TestCase):
102 | conf = {'dir': ''}
103 |
104 | def test_no_config(self):
105 | with patch.object(utils, 'parse_openstack_ini',
106 | side_effect=EnvironmentError()):
107 | res = test_cinder.nova_secure(self.conf)
108 | self.assertEqual(res.result, Result.SKIP)
109 |
110 | def _run_with_config(self, os_ini):
111 | with patch.object(utils, 'parse_openstack_ini', return_value=os_ini):
112 | res = test_cinder.nova_secure(self.conf)
113 | return res
114 |
115 | def test_bad(self):
116 | res = self._run_with_config({'DEFAULT': {
117 | 'nova_api_insecure': 'true'}})
118 | self.assertEqual(res.result, Result.FAIL)
119 |
120 | def test_ok(self):
121 | res = self._run_with_config({'DEFAULT': {
122 | 'nova_api_insecure': 'false'}})
123 | self.assertEqual(res.result, Result.PASS)
124 |
125 |
126 | class GlanceSecure(unittest.TestCase):
127 | conf = {'dir': ''}
128 |
129 | def test_no_config(self):
130 | with patch.object(utils, 'parse_openstack_ini',
131 | side_effect=EnvironmentError()):
132 | res = test_cinder.glance_secure(self.conf)
133 | self.assertEqual(res.result, Result.SKIP)
134 |
135 | def _run_with_config(self, os_ini):
136 | with patch.object(utils, 'parse_openstack_ini', return_value=os_ini):
137 | res = test_cinder.glance_secure(self.conf)
138 | return res
139 |
140 | def test_bad(self):
141 | res = self._run_with_config({'DEFAULT': {
142 | 'glance_api_insecure': 'true'}})
143 | self.assertEqual(res.result, Result.FAIL)
144 |
145 | def test_ok(self):
146 | res = self._run_with_config({'DEFAULT': {
147 | 'glance_api_insecure': 'false'}})
148 | self.assertEqual(res.result, Result.PASS)
149 |
150 |
151 | class NasSecurity(unittest.TestCase):
152 | conf = {'dir': ''}
153 |
154 | def test_no_config(self):
155 | with patch.object(utils, 'parse_openstack_ini',
156 | side_effect=EnvironmentError()):
157 | res = test_cinder.nas_security(self.conf)
158 | self.assertEqual(res.result, Result.SKIP)
159 |
160 | def _run_with_config(self, os_ini):
161 | with patch.object(utils, 'parse_openstack_ini', return_value=os_ini):
162 | res = test_cinder.nas_security(self.conf)
163 | return res
164 |
165 | def test_bad_ops(self):
166 | res = self._run_with_config({'DEFAULT': {
167 | 'nas_secure_file_operations': 'false',
168 | 'nas_secure_file_permissions': 'auto'}})
169 | self.assertEqual(res.result, Result.FAIL)
170 |
171 | def test_bad_perm(self):
172 | res = self._run_with_config({'DEFAULT': {
173 | 'nas_secure_file_operations': 'auto',
174 | 'nas_secure_file_permissions': 'false'}})
175 | self.assertEqual(res.result, Result.FAIL)
176 |
177 | def test_ok(self):
178 | res = self._run_with_config({'DEFAULT': {
179 | 'nas_secure_file_operations': 'auto',
180 | 'nas_secure_file_permissions': 'auto'}})
181 | self.assertEqual(res.result, Result.PASS)
182 |
183 | def test_ok_default(self):
184 | res = self._run_with_config({'DEFAULT': {}})
185 | self.assertEqual(res.result, Result.PASS)
186 |
187 |
188 | class BodySize(unittest.TestCase):
189 | conf = {'dir': ''}
190 |
191 | def test_no_config(self):
192 | with patch.object(utils, 'parse_openstack_ini',
193 | side_effect=EnvironmentError()):
194 | res = test_cinder.body_size(self.conf)
195 | self.assertEqual(res.result, Result.SKIP)
196 |
197 | def _run_with_config(self, os_ini):
198 | with patch.object(utils, 'parse_openstack_ini', return_value=os_ini):
199 | res = test_cinder.body_size(self.conf)
200 | return res
201 |
202 | def test_bad_def(self):
203 | res = self._run_with_config({
204 | 'DEFAULT': {'osapi_max_request_body_size': '114688'},
205 | 'oslo_middleware': {'max_request_body_size': '999999'}})
206 | self.assertEqual(res.result, Result.FAIL)
207 |
208 | def test_bad_middle(self):
209 | res = self._run_with_config({
210 | 'DEFAULT': {'osapi_max_request_body_size': '999999'},
211 | 'oslo_middleware': {'max_request_body_size': '114688'}})
212 | self.assertEqual(res.result, Result.FAIL)
213 |
214 | def test_ok(self):
215 | res = self._run_with_config({
216 | 'DEFAULT': {'osapi_max_request_body_size': '114688'},
217 | 'oslo_middleware': {'max_request_body_size': '114688'}})
218 | self.assertEqual(res.result, Result.PASS)
219 |
220 | def test_ok_default(self):
221 | res = self._run_with_config({})
222 | self.assertEqual(res.result, Result.PASS)
223 |
--------------------------------------------------------------------------------
/reconbf/modules/test_upgrades.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Hewlett Packard Enterprise Development LP
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | from reconbf.lib.logger import logger
16 | import reconbf.lib.test_class as test_class
17 | from reconbf.lib.result import GroupTestResult
18 | from reconbf.lib.result import TestResult
19 | from reconbf.lib.result import Result
20 |
21 | import os
22 | import platform
23 | import string
24 |
25 |
26 | @test_class.explanation(
27 | """
28 | Protection name: Reboot required
29 |
30 | Check: Verify if the system thinks a reboot is required
31 |
32 | Purpose: Some distributions will report whether a reboot is
33 | required due to recently installed packages. This usually
34 | means a new binary cannot be easily restarted: like the
35 | kernel or the init system.
36 | """)
37 | def reboot_required():
38 | try:
39 | distro, _version, _name = platform.linux_distribution()
40 | except Exception:
41 | return TestResult(Result.SKIP, "Could not detect distribution")
42 |
43 | if distro in ('Ubuntu', 'Debian'):
44 | if os.path.isfile('/var/run/reboot-required'):
45 | try:
46 | with open('/var/run/reboot-required.pkgs', 'r') as f:
47 | packages = set(line.strip() for line in f.readlines())
48 | except Exception:
49 | packages = None
50 |
51 | if packages:
52 | packages = ', '.join(sorted(packages))
53 | msg = "Reboot is required to update: %s" % packages
54 | else:
55 | msg = "Reboot is required"
56 | return TestResult(Result.FAIL, msg)
57 |
58 | else:
59 | return TestResult(Result.PASS)
60 |
61 | else:
62 | return TestResult(Result.SKIP, "Unknown distribution")
63 |
64 |
65 | @test_class.explanation(
66 | """
67 | Protection name: Running processes have all corresponding files
68 |
69 | Check: Checks that each process running on the system uses
70 | files which are present on the disk.
71 |
72 | Purpose: Usually every running process will have the corresponding
73 | executable file and library available all the time. Anything
74 | different is an uncommon situation. It can happen for example
75 | because: file was deleted on purpose to avoid detection, package
76 | has been upgraded but the process was not restarted (potentially
77 | still vulnerable), etc.
78 | """)
79 | def missing_process_binaries():
80 | results = GroupTestResult()
81 |
82 | for pid in os.listdir('/proc'):
83 | if not pid.isdigit():
84 | continue
85 |
86 | try:
87 | main_binary = os.readlink(os.path.join('/proc', pid, 'exe'))
88 |
89 | missing_main = False
90 | missing = set()
91 |
92 | links = os.listdir(os.path.join('/proc', pid, 'map_files'))
93 | for link in links:
94 | link_path = os.readlink(os.path.join('/proc', pid, 'map_files',
95 | link))
96 | if link_path.endswith(' (deleted)'):
97 | if link_path == main_binary:
98 | missing_main = True
99 | else:
100 | link_path = link_path[:-10]
101 | # only check libraries, data files can go missing
102 | # without issues
103 | file_name = os.path.basename(link_path)
104 | if file_name.endswith('.so') or '.so.' in file_name:
105 | missing.add(link_path)
106 |
107 | if main_binary.endswith(' (deleted)'):
108 | main_binary = main_binary[:-10]
109 |
110 | process = "pid %s, %s" % (pid, main_binary)
111 | missing_list = []
112 | if missing_main:
113 | missing_list.append('main binary')
114 | missing_list.extend(sorted(missing))
115 |
116 | if missing_list:
117 | msg = "Missing: %s" % ', '.join(missing_list)
118 | results.add_result(process, TestResult(Result.FAIL, msg))
119 | else:
120 | results.add_result(process, TestResult(Result.PASS))
121 |
122 | except EnvironmentError:
123 | # this pid can disappear at any point, so on any read failure,
124 | # just continue with the next process
125 | continue
126 |
127 | return results
128 |
129 |
130 | def _parse_deb_repo_line(line):
131 | # this parses only the one-line format, because honestly, who uses deb822
132 | # in their sources file...
133 | line = line.strip()
134 |
135 | # cut off the comments
136 | comment_pos = line.find('#')
137 | if comment_pos != -1:
138 | line = line[:comment_pos]
139 |
140 | # ignore empty lines
141 | if not line:
142 | return
143 |
144 | # everything's split by whitespace
145 | parts = line.split()
146 | pkg_type = parts.pop(0)
147 | while parts:
148 | if '=' in parts[0]:
149 | # just ignore the options...
150 | parts.pop(0)
151 | else:
152 | break
153 |
154 | if not parts:
155 | logger.warning('deb entry "%s" missing uri, ignoring', line)
156 | return
157 | uri = parts.pop(0)
158 |
159 | if not parts:
160 | logger.warning('deb entry "%s" missing suite, ignoring', line)
161 | return
162 | suite = parts.pop(0)
163 | components = parts
164 | return {
165 | 'type': pkg_type,
166 | 'uri': uri,
167 | 'suite': suite,
168 | 'components': components,
169 | }
170 |
171 |
172 | SOURCES_LIST_CHARS = set(string.ascii_letters + string.digits + '_-.')
173 |
174 |
175 | def _get_deb_repos():
176 | repos = []
177 | files = ['/etc/apt/sources.list']
178 |
179 | try:
180 | for name in os.listdir('/etc/apt/sources.list.d'):
181 | if not name.endswith('.list'):
182 | continue
183 | if set(name).difference(SOURCES_LIST_CHARS):
184 | # unexpected characters in the name, ignored by apt by default
185 | continue
186 |
187 | files.append('/etc/apt/sources.list.d/' + name)
188 | except OSError:
189 | # if the directory cannot be read, it's ok to ignore it
190 | pass
191 |
192 | for name in files:
193 | try:
194 | with open(name, 'r') as f:
195 | for line in f:
196 | repo = _parse_deb_repo_line(line)
197 | if repo:
198 | repos.append(repo)
199 | except EnvironmentError:
200 | logger.warning('cannot read repo list "%s"', name)
201 | continue
202 |
203 | return repos
204 |
205 |
206 | @test_class.explanation(
207 | """
208 | Protection name: Security updates in repo lists
209 |
210 | Check: Will the package manager look at security
211 | updates when a standard system update is triggered.
212 |
213 | Purpose: Many systems use a different repository
214 | for standard releases and for security updates. This
215 | is due to multiple reasons (security-only update
216 | streams, mirror delays, etc.), but means a system
217 | may seem to be updating ok even if no security
218 | fixes are pulled.
219 | Currently, this test supports Debian and Ubuntu
220 | systems only.
221 | """)
222 | def security_updates():
223 | try:
224 | distro, _version, version_name = platform.linux_distribution()
225 | except Exception:
226 | return TestResult(Result.SKIP, "Could not detect distribution")
227 |
228 | if distro in ('Ubuntu', 'Debian'):
229 | repos = _get_deb_repos()
230 |
231 | security_suite = version_name + '-security'
232 | found_security = False
233 |
234 | for repo in repos:
235 | if repo['type'] != 'deb':
236 | continue
237 |
238 | if distro == 'Ubuntu' and repo['suite'] == security_suite:
239 | found_security = True
240 | break
241 | if (distro == 'Debian' and 'http://security.debian.org' in
242 | repo['uri']):
243 | found_security = True
244 | break
245 |
246 | if found_security:
247 | return TestResult(Result.PASS, "Security repo present")
248 | else:
249 | return TestResult(Result.FAIL,
250 | "Upstream security repo not configured")
251 |
252 | else:
253 | return TestResult(Result.SKIP, "Unknown distribution")
254 |
--------------------------------------------------------------------------------
/reconbf/modules/test_manila.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Hewlett Packard Enterprise Development LP
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | from reconbf.lib import test_class
16 | from reconbf.lib.result import GroupTestResult
17 | from reconbf.lib.result import Result
18 | from reconbf.lib.result import TestResult
19 | from reconbf.lib import utils
20 | import grp
21 | import os
22 | import pwd
23 | import functools
24 |
25 |
26 | def _conf_location():
27 | return {'dir': '/etc/manila'}
28 |
29 |
30 | def _conf_details():
31 | config = _conf_location().copy()
32 | config['user'] = 'root'
33 | config['group'] = 'manila'
34 | return config
35 |
36 |
37 | @test_class.explanation("""
38 | Protection name: Config permissions
39 |
40 | Check: Are manila config permissions ok
41 |
42 | Purpose: Manila config files contain authentication
43 | details and need to be protected. Ensure that
44 | they're only available to the service.
45 | """)
46 | @test_class.set_mapping("OpenStack:Check-Shared-01",
47 | "OpenStack:Check-Shared-02")
48 | @test_class.takes_config(_conf_details)
49 | def config_permission(config):
50 | try:
51 | user = pwd.getpwnam(config['user'])
52 | except KeyError:
53 | return TestResult(Result.SKIP,
54 | 'Could not find user "%s"' % config['user'])
55 |
56 | try:
57 | group = grp.getgrnam(config['group'])
58 | except KeyError:
59 | return TestResult(Result.SKIP,
60 | 'Could not find group "%s"' % config['group'])
61 |
62 | result = GroupTestResult()
63 | files = ['manila.conf', 'api-paste.ini', 'policy.json', 'rootwrap.conf']
64 | for f in files:
65 | path = os.path.join(config['dir'], f)
66 | result.add_result(path,
67 | utils.validate_permissions(path, 0o640, user.pw_uid,
68 | group.gr_gid))
69 | return result
70 |
71 |
72 | def _checks_config(f):
73 | @functools.wraps(f)
74 | def wrapper(config):
75 | try:
76 | path = os.path.join(config['dir'], 'manila.conf')
77 | conf = utils.parse_openstack_ini(path)
78 | except EnvironmentError:
79 | return TestResult(Result.SKIP, 'cannot read manila config file')
80 | return f(conf)
81 | return wrapper
82 |
83 |
84 | @test_class.explanation("""
85 | Protection name: Authentication strategy
86 |
87 | Check: Make sure proper authentication is used
88 |
89 | Purpose: There are multiple authentication backends
90 | available. Manila should be configured to authenticate
91 | against keystone rather than test backends.
92 | """)
93 | @test_class.set_mapping("OpenStack:Check-Shared-03")
94 | @test_class.takes_config(_conf_location)
95 | @_checks_config
96 | def auth(conf):
97 | auth = conf.get('DEFAULT', {}).get('auth_strategy', 'keystone')
98 | if auth != 'keystone':
99 | return TestResult(Result.FAIL,
100 | 'authentication should be done by keystone')
101 | else:
102 | return TestResult(Result.PASS)
103 |
104 |
105 | @test_class.explanation("""
106 | Protection name: Keystone api access
107 |
108 | Check: Does Keystone access use secure connection
109 |
110 | Purpose: OpenStack components communicate with each other
111 | using various protocols and the communication might
112 | involve sensitive / confidential data. An attacker may
113 | try to eavesdrop on the channel in order to get access to
114 | sensitive information. Thus all the components must
115 | communicate with each other using a secured communication
116 | protocol.
117 | """)
118 | @test_class.set_mapping("OpenStack:Check-Shared-04")
119 | @test_class.takes_config(_conf_location)
120 | @_checks_config
121 | def keystone_secure(conf):
122 | protocol = conf.get('keystone_authtoken', {}).get('auth_protocol', 'https')
123 | identity = conf.get('keystone_authtoken', {}).get('identity_uri', 'https:')
124 |
125 | if not identity.startswith('https:'):
126 | return TestResult(Result.FAIL, 'keystone access is not secure')
127 | if protocol != 'https':
128 | return TestResult(Result.FAIL, 'keystone access is not secure')
129 |
130 | return TestResult(Result.PASS)
131 |
132 |
133 | @test_class.explanation("""
134 | Protection name: Nova api access
135 |
136 | Check: Does Nova access use secure connection
137 |
138 | Purpose: OpenStack components communicate with each other
139 | using various protocols and the communication might
140 | involve sensitive / confidential data. An attacker may
141 | try to eavesdrop on the channel in order to get access to
142 | sensitive information. Thus all the components must
143 | communicate with each other using a secured communication
144 | protocol.
145 | """)
146 | @test_class.set_mapping("OpenStack:Check-Shared-05")
147 | @test_class.takes_config(_conf_location)
148 | @_checks_config
149 | def nova_secure(conf):
150 | insecure = conf.get('DEFAULT', {}).get('nova_api_insecure', 'false')
151 | insecure = insecure.lower() == 'true'
152 |
153 | if insecure:
154 | return TestResult(Result.FAIL, 'nova access is not secure')
155 | else:
156 | return TestResult(Result.PASS)
157 |
158 |
159 | @test_class.explanation("""
160 | Protection name: Neutron api access
161 |
162 | Check: Does Neutron access use secure connection
163 |
164 | Purpose: OpenStack components communicate with each other
165 | using various protocols and the communication might
166 | involve sensitive / confidential data. An attacker may
167 | try to eavesdrop on the channel in order to get access to
168 | sensitive information. Thus all the components must
169 | communicate with each other using a secured communication
170 | protocol.
171 | """)
172 | @test_class.set_mapping("OpenStack:Check-Shared-06")
173 | @test_class.takes_config(_conf_location)
174 | @_checks_config
175 | def neutron_secure(conf):
176 | insecure = conf.get('DEFAULT', {}).get('neutron_api_insecure', 'false')
177 | insecure = insecure.lower() == 'true'
178 |
179 | if insecure:
180 | return TestResult(Result.FAIL, 'neutron access is not secure')
181 | else:
182 | return TestResult(Result.PASS)
183 |
184 |
185 | @test_class.explanation("""
186 | Protection name: Cinder api access
187 |
188 | Check: Does Cinder access use secure connection
189 |
190 | Purpose: OpenStack components communicate with each other
191 | using various protocols and the communication might
192 | involve sensitive / confidential data. An attacker may
193 | try to eavesdrop on the channel in order to get access to
194 | sensitive information. Thus all the components must
195 | communicate with each other using a secured communication
196 | protocol.
197 | """)
198 | @test_class.set_mapping("OpenStack:Check-Shared-07")
199 | @test_class.takes_config(_conf_location)
200 | @_checks_config
201 | def cinder_secure(conf):
202 | insecure = conf.get('DEFAULT', {}).get('cinder_api_insecure', 'false')
203 | insecure = insecure.lower() == 'true'
204 |
205 | if insecure:
206 | return TestResult(Result.FAIL, 'cinder access is not secure')
207 | else:
208 | return TestResult(Result.PASS)
209 |
210 |
211 | @test_class.explanation("""
212 | Protection name: Body size limit
213 |
214 | Check: Ensure large requests are stopped.
215 |
216 | Purpose: Large requests can cause a denial of service.
217 | Setting up a limit ensures that they're rejected without
218 | full processing.
219 | """)
220 | @test_class.set_mapping("OpenStack:Check-Shared-08")
221 | @test_class.takes_config(_conf_location)
222 | @_checks_config
223 | def body_size(conf):
224 | osapi_max_body_size = int(conf.get('DEFAULT', {}).get(
225 | 'osapi_max_request_body_size', '114688'))
226 | oslo_max_body_size = int(conf.get('oslo_middleware', {}).get(
227 | 'max_request_body_size', '114688'))
228 |
229 | results = GroupTestResult()
230 |
231 | res_name = 'osapi body size'
232 | if osapi_max_body_size <= 114688:
233 | results.add_result(res_name, TestResult(Result.PASS))
234 | else:
235 | results.add_result(res_name, TestResult(
236 | Result.FAIL, 'osapi allows too big request bodies'))
237 |
238 | res_name = 'oslo body size'
239 | if oslo_max_body_size <= 114688:
240 | results.add_result(res_name, TestResult(Result.PASS))
241 | else:
242 | results.add_result(res_name, TestResult(
243 | Result.FAIL, 'middleware allows too big request bodies'))
244 |
245 | return results
246 |
--------------------------------------------------------------------------------
/reconbf/modules/test_sec.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Hewlett Packard Enterprise Development LP
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | from reconbf.lib.logger import logger
16 | import reconbf.lib.test_class as test_class
17 | from reconbf.lib.result import GroupTestResult
18 | from reconbf.lib.result import Result
19 | from reconbf.lib.result import TestResult
20 | from reconbf.lib import utils
21 |
22 | from subprocess import PIPE
23 | from subprocess import Popen
24 |
25 |
26 | def _conf_test_shellshock():
27 | return {"exploit_command":
28 | "env X='() { :;}; echo vulnerable' bash -c 'echo this is a test'"
29 | }
30 |
31 |
32 | @test_class.takes_config(_conf_test_shellshock)
33 | @test_class.explanation(
34 | """
35 | Protection name: Bash not vulnerable to shellshock
36 |
37 | Check: Runs shellshock test payload and validates output
38 |
39 | Purpose: A version of bash on the system which is vulnerable to shellshock
40 | can expose the system to many types of attacks. There is no good way to
41 | take stock of how many processes running on they system use Bash in a
42 | potentially vulnerable way, so the only way to prevent exploitation is to
43 | use a version of bash which has been patched.
44 | """)
45 | def test_shellshock(config):
46 | logger.debug("Testing shell for 'shellshock/bashbug' vulnerability.")
47 |
48 | try:
49 | cmd = config['exploit_command']
50 | except KeyError:
51 | logger.error("Can't find exploit command for shellshock test")
52 | else:
53 |
54 | p = Popen(cmd, stdout=PIPE, stderr=PIPE, shell=True)
55 | stdout, stderr = p.communicate()
56 |
57 | if b'vulnerable' in stdout:
58 | reason = "System is vulnerable to Shellshock/Bashbug."
59 | logger.info(reason)
60 | result = Result.FAIL
61 | else:
62 | reason = "System is not vulnerable to Shellshock/Bashbug."
63 | logger.info(reason)
64 | result = Result.PASS
65 | return TestResult(result, reason)
66 |
67 |
68 | def _sysctl_report_failure(pattern, value):
69 | expected = "{} {}".format(pattern[0].replace("_", " "), pattern[1])
70 | return "expected {}, actual {}".format(expected, value)
71 |
72 |
73 | def _sysctl_check(pattern, value):
74 | patt_type = pattern[0]
75 | if patt_type == "one_of":
76 | return value in pattern[1]
77 |
78 | elif patt_type == "none_of":
79 | return value not in pattern[1]
80 |
81 | elif patt_type == "match":
82 | return value == pattern[1]
83 |
84 | elif patt_type == "at_least":
85 | return int(value) >= int(pattern[1])
86 |
87 | else:
88 | raise ValueError("unknown pattern '{}'".format(patt_type))
89 |
90 |
91 | def _sysctl_description(key, pattern):
92 | try:
93 | _, _, description = pattern
94 | return description
95 | except ValueError:
96 | return key
97 |
98 |
99 | def _conf_test_sysctl_values():
100 |
101 | return {
102 | "fs/suid_dumpable": ("one_of", ["0", "2"], "SUID coredump handling"),
103 | "net/ipv4/tcp_syncookies": ("match", "1", "TCP syncookie protection"),
104 | "net/ipv4/tcp_max_syn_backlog": ("match", "4096"),
105 | "net/ipv4/conf/all/rp_filter": ("match", "1"),
106 | "net/ipv4/conf/all/accept_source_route": ("match", "0"),
107 | "net/ipv4/conf/all/accept_redirects": ("match", "0"),
108 | "net/ipv4/conf/all/secure_redirects": ("match", "0"),
109 | "net/ipv4/conf/default/accept_redirects": ("match", "0"),
110 | "net/ipv4/conf/default/secure_redirects": ("match", "0"),
111 | "net/ipv4/conf/all/send_redirects": ("match", "0"),
112 | "net/ipv4/conf/default/send_redirects": ("match", "0"),
113 | "net/ipv4/icmp_echo_ignore_broadcasts": ("match", "1"),
114 | "net/ipv4/icmp_ignore_bogus_error_responses": ("match", "1"),
115 | "net/ipv4/ip_forward": ("match", "0"),
116 | "net/ipv4/conf/all/log_martians": ("match", "1"),
117 | "net/ipv4/conf/default/rp_filter": ("match", "1"),
118 | "vm/swappiness": ("match", "0"),
119 | "vm/mmap_min_addr": ("one_of", ["4096", "8192", "16384", "32768",
120 | "65536", "131072"]),
121 | "kernel/core_pattern": ("match", "core"),
122 | "kernel/randomize_va_space": ("match", "2"),
123 | "kernel/exec-shield": ("match", "1"),
124 | "kernel/kptr_restrict": ("one_of", ["1", "2"],
125 | "Kernel pointer hiding"),
126 |
127 | # Affects kernels >= 3.6. Can be mitigated by setting
128 | # net.ipv4.tcp_challenge_act_limit to a large number.
129 | #
130 | # Proposed fix: https://github.com/torvalds/linux/commit/75ff39cc
131 | #
132 | # New default will be 1000, other recommendations are
133 | # 1073741823 (unsigned long long) and 999999999.
134 | #
135 | "net/ipv4/tcp_challenge_ack_limit":
136 | ("at_least", "1000", "CVE-2016-5696 challenge ack counter")
137 | }
138 |
139 |
140 | @test_class.takes_config(_conf_test_sysctl_values)
141 | @test_class.explanation(
142 | """
143 | Protection name: Sysctl settings set securely
144 |
145 | Check: Validates that sysctl values are set as specified in the
146 | configuration file.
147 |
148 | Purpose: Sysctl is used to configure kernel parameters. Many of these
149 | parameters can be used to tune and harden the security of a system. This
150 | check verifies that secure values have been used where applicable.
151 | """)
152 | def test_sysctl_values(checks):
153 | results = GroupTestResult()
154 |
155 | if not checks:
156 | return TestResult(Result.SKIP, "Unable to load module config file")
157 |
158 | for key, pattern in checks.items():
159 | description = _sysctl_description(key, pattern)
160 | try:
161 | value = utils.get_sysctl_value(key)
162 | result = None
163 | if _sysctl_check(pattern, value):
164 | result = TestResult(Result.PASS)
165 | else:
166 | error = _sysctl_report_failure(pattern, value)
167 | result = TestResult(Result.FAIL, notes=error)
168 |
169 | results.add_result(description, result)
170 |
171 | except utils.ValNotFound:
172 | notes = "Could not find a value for {}".format(key)
173 | results.add_result(description,
174 | TestResult(Result.SKIP, notes=notes))
175 |
176 | return results
177 |
178 |
179 | @test_class.explanation("""
180 | Protection name: Certificate expiration check.
181 |
182 | Check: Run the command "openssl verify cer.pem" and ensure the
183 | stdout returns "OK" in the message.
184 |
185 | Purpose: Certificates create a level of trust between the system
186 | and the packages and pages that are signed with the certificates
187 | private key. Ensuring that these certificates are both not expired
188 | and are properly created certificiates will help confirm that
189 | whatever communication or package that is received is valid.
190 | """)
191 | def test_certs():
192 | logger.debug("Testing bundled certificate validity & expiration.")
193 |
194 | certList = []
195 | certStore = '/etc/ssl/certs'
196 | result = None
197 | notes = ""
198 |
199 | # use utils to get list of certificates
200 | certList = utils.get_files_list_from_dir(certStore)
201 |
202 | if certList is None:
203 | notes = "/etc/ssl/certs is empty, please check on-system certificates."
204 | logger.debug(notes)
205 | result = Result.SKIP
206 | return TestResult(result, notes)
207 |
208 | for cert in certList:
209 | try:
210 | p = Popen(['openssl', 'verify', cert], stdout=PIPE, shell=False)
211 | stdout = p.communicate()
212 | if b"OK" in stdout[0]:
213 | logger.debug("Certificate verification success for: %s", cert)
214 | if result is None:
215 | result = Result.PASS
216 | else:
217 | result = Result.FAIL
218 | if notes is "":
219 | notes += "Error validating certificate: " + cert
220 | else:
221 | notes += ", " + cert
222 | logger.debug("Certificate verification failure for: %s", cert)
223 |
224 | except ValueError:
225 | logger.exception("Error running 'openssl verify %s'", cert)
226 | result = Result.SKIP
227 |
228 | logger.debug("Completed on-system certificate validation tests.")
229 | return TestResult(result, notes)
230 |
--------------------------------------------------------------------------------
/reconbf/modules/test_horizon.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Hewlett Packard Enterprise Development LP
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | from reconbf.lib import test_class
16 | from reconbf.lib.result import GroupTestResult
17 | from reconbf.lib.result import Result
18 | from reconbf.lib.result import TestResult
19 | from reconbf.lib import utils
20 | from reconbf.lib.logger import logger
21 | import grp
22 | import os
23 | import pwd
24 | import ast
25 | import functools
26 |
27 |
28 | class NotConstant(Exception):
29 | pass
30 |
31 |
32 | if hasattr(ast, 'Bytes'):
33 | # py3
34 | str_types = (ast.Str, ast.Bytes)
35 | else:
36 | # py2
37 | str_types = (ast.Str,)
38 |
39 |
40 | def _resolve_constant(node):
41 | if isinstance(node, str_types):
42 | return node.s
43 | elif isinstance(node, ast.Num):
44 | return node.n
45 | elif isinstance(node, ast.List):
46 | return [_resolve_constant(e) for e in node.elts]
47 | elif isinstance(node, ast.Set):
48 | return set(_resolve_constant(e) for e in node.elts)
49 | elif isinstance(node, ast.Tuple):
50 | return tuple(_resolve_constant(e) for e in node.elts)
51 | elif isinstance(node, ast.Dict):
52 | res = {}
53 | for k, v in zip(node.keys, node.values):
54 | res[_resolve_constant(k)] = _resolve_constant(v)
55 | return res
56 | else:
57 | raise NotConstant()
58 |
59 |
60 | # This parses .py files for simple variable assignment
61 | # I'm making an assumption that the file will not contain any complicated code,
62 | # or features that would be version-dependent. Since Horizon aims for
63 | # compatibility with common systems, this should be a fair assumption.
64 | @utils.idempotent
65 | def _read_config(path):
66 | with open(path, 'r') as f:
67 | conf_content = f.read()
68 | return _parse_config(conf_content)
69 |
70 |
71 | def _parse_config(conf_content):
72 |
73 | conf_ast = ast.parse(conf_content)
74 | config = {}
75 |
76 | for statement in conf_ast.body:
77 | if not isinstance(statement, ast.Assign):
78 | # ignore complicated statements
79 | continue
80 |
81 | target = statement.targets[0]
82 | if isinstance(target, ast.Name):
83 | name = target.id
84 | elif (isinstance(target, ast.Subscript) and
85 | isinstance(target.value, ast.Name) and
86 | isinstance(target.slice, ast.Index) and
87 | isinstance(target.slice.value, ast.Str)):
88 | # cheat a bit since this name is illegal for variable
89 | name = "%s[%s]" % (target.value.id, target.slice.value.s)
90 | else:
91 | logger.warning('cannot parse assignment at line %i',
92 | statement.lineno)
93 | continue
94 |
95 | try:
96 | config[name] = _resolve_constant(statement.value)
97 | except NotConstant:
98 | logger.warning('value assigned to %s in horizon config could not '
99 | 'be parsed as a constant', name)
100 | continue
101 |
102 | return config
103 |
104 |
105 | def _checks_config(f):
106 | @functools.wraps(f)
107 | def wrapper(config):
108 | try:
109 | path = os.path.join(config['dir'], 'local_settings.py')
110 | conf = _read_config(path)
111 | except EnvironmentError:
112 | return TestResult(Result.SKIP, 'cannot read horizon config file')
113 | return f(conf)
114 | return wrapper
115 |
116 |
117 | def _conf_location():
118 | return {'dir': '/etc/openstack-dashboard'}
119 |
120 |
121 | def _conf_details():
122 | config = _conf_location().copy()
123 | config['user'] = 'root'
124 | config['group'] = 'horizon'
125 | return config
126 |
127 |
128 | @test_class.explanation("""
129 | Protection name: Config permissions
130 |
131 | Check: Are horizon config permissions ok
132 |
133 | Purpose: Horizon config files contain authentication
134 | details and need to be protected. Ensure that
135 | they're only available to the service.
136 | """)
137 | @test_class.set_mapping("OpenStack:Check-Dashboard-01",
138 | "OpenStack:Check-Dashboard-02")
139 | @test_class.takes_config(_conf_details)
140 | def config_permission(config):
141 | try:
142 | user = pwd.getpwnam(config['user'])
143 | except KeyError:
144 | return TestResult(Result.SKIP,
145 | 'Could not find user "%s"' % config['user'])
146 |
147 | try:
148 | group = grp.getgrnam(config['group'])
149 | except KeyError:
150 | return TestResult(Result.SKIP,
151 | 'Could not find group "%s"' % config['group'])
152 |
153 | result = GroupTestResult()
154 | files = ['nova.conf',
155 | 'api-paste.ini',
156 | 'policy.json',
157 | 'rootwrap.conf',
158 | ]
159 | for f in files:
160 | path = os.path.join(config['dir'], f)
161 | result.add_result(path,
162 | utils.validate_permissions(path, 0o640, user.pw_uid,
163 | group.gr_gid))
164 | return result
165 |
166 |
167 | @test_class.explanation("""
168 | Protection name: Secure CSRF cookie
169 |
170 | Check: Verify that the CSRF cookies have the secure attribute
171 |
172 | Purpose: Prevent sending the CSRF cookie over unencrypted
173 | connections. This makes certain classes of attack harder.
174 | """)
175 | @test_class.set_mapping("OpenStack:Check-Dashboard-04")
176 | @test_class.takes_config(_conf_location)
177 | @_checks_config
178 | def csrf_cookie(conf):
179 | if conf.get('CSRF_COOKIE_SECURE', True):
180 | return TestResult(Result.PASS)
181 | else:
182 | return TestResult(Result.FAIL, 'CSRF_COOKIE_SECURE should be enabled')
183 |
184 |
185 | @test_class.explanation("""
186 | Protection name: Secure the session cookie
187 |
188 | Check: Verify that the session cookies have the secure attribute
189 |
190 | Purpose: Prevent sending the session cookie over unencrypted
191 | connections. This makes session hijacking harder.
192 | """)
193 | @test_class.set_mapping("OpenStack:Check-Dashboard-05")
194 | @test_class.takes_config(_conf_location)
195 | @_checks_config
196 | def session_cookie(conf):
197 | if conf.get('SESSION_COOKIE_SECURE', True):
198 | return TestResult(Result.PASS)
199 | else:
200 | return TestResult(Result.FAIL,
201 | 'SESSION_COOKIE_SECURE should be enabled')
202 |
203 |
204 | @test_class.explanation("""
205 | Protection name: Prevent session cookie access
206 |
207 | Check: Verify that the session cookies have the httponly attribute
208 |
209 | Purpose: Prevent the session cookie from being accessed by the
210 | scripts running on the website. This makes session hijacking
211 | harder.
212 | """)
213 | @test_class.set_mapping("OpenStack:Check-Dashboard-06")
214 | @test_class.takes_config(_conf_location)
215 | @_checks_config
216 | def session_cookie_http(conf):
217 | if conf.get('SESSION_COOKIE_HTTPONLY', True):
218 | return TestResult(Result.PASS)
219 | else:
220 | return TestResult(Result.FAIL,
221 | 'SESSION_COOKIE_HTTPONLY should be enabled')
222 |
223 |
224 | @test_class.explanation("""
225 | Protection name: Prevent autocompletion on login forms
226 |
227 | Check: Verify that logins are not autocompleted
228 |
229 | Purpose: Disabling login data autocompletion makes it harder
230 | to find out any part of the credentials used by the previous user.
231 | """)
232 | @test_class.set_mapping("OpenStack:Check-Dashboard-07")
233 | @test_class.takes_config(_conf_location)
234 | @_checks_config
235 | def password_autocomplete(conf):
236 | setting = conf.get('HORIZON_CONFIG[password_autocomplete]', "off")
237 | if setting in (False, "off"):
238 | return TestResult(Result.PASS)
239 | else:
240 | return TestResult(Result.FAIL,
241 | 'password_autocomplete should be disabled')
242 |
243 |
244 | @test_class.explanation("""
245 | Protection name: Disable password reveal
246 |
247 | Check: Verify that password fields are not revealed
248 |
249 | Purpose: Disabling login data autocompletion makes it harder
250 | to find out any part of the credentials used by the previous user.
251 | """)
252 | @test_class.set_mapping("OpenStack:Check-Dashboard-08")
253 | @test_class.takes_config(_conf_location)
254 | @_checks_config
255 | def password_reveal(conf):
256 | setting = conf.get('HORIZON_CONFIG[disable_password_reveal]', False)
257 | if setting:
258 | return TestResult(Result.PASS)
259 | else:
260 | return TestResult(Result.FAIL,
261 | 'password_autocomplete should be disabled')
262 |
--------------------------------------------------------------------------------
/reconbf/modules/test_php.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Hewlett Packard Enterprise Development LP
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | from reconbf.lib import test_class
16 | from reconbf.lib import utils
17 | from reconbf.lib.logger import logger
18 | from reconbf.lib.result import GroupTestResult
19 | from reconbf.lib.result import Result
20 | from reconbf.lib.result import TestResult
21 |
22 | import json
23 | import os
24 | import subprocess
25 |
26 |
27 | def _find_checker(path):
28 | # check for project-specific checker
29 | command = os.path.join(
30 | path, 'vendor/sensiolabs/security-checker/security-checker')
31 | if os.path.isfile(command):
32 | return command
33 |
34 | # check for systemwide checker installation
35 | for path in os.environ.get('PATH', "").split(":"):
36 | command = os.path.join(path, 'security-checker')
37 | if os.path.isfile(command):
38 | return command
39 |
40 | return None
41 |
42 |
43 | def _conf_app_paths():
44 | return []
45 |
46 |
47 | @test_class.explanation("""
48 | Protection name: Composer modules security
49 |
50 | Check: Validate the list of installed php/composer modules
51 | against the sensio database of known vulnerabilities.
52 | The check requires open internet connection and the
53 | sensiolabs/security-checker module installed in the app.
54 |
55 | Purpose: Web applications may be vulnerable because of issues
56 | not solved by the systemwide upgrade systems. Sensiolabs
57 | maintains a database of issues in php/composer modules.
58 | More details about the issues can be found by either running
59 | the checker independently or checking:
60 | https://security.sensiolabs.org/check
61 | """)
62 | @test_class.takes_config(_conf_app_paths)
63 | def composer_security(app_paths):
64 | if not app_paths:
65 | return TestResult(Result.SKIP, "no web applications configured")
66 |
67 | results = GroupTestResult()
68 |
69 | for path in app_paths:
70 | try:
71 | with open(os.path.join(path, 'composer.lock'), 'r') as f:
72 | lock_contents = f.read()
73 | except EnvironmentError:
74 | results.add_result(path, TestResult(Result.SKIP,
75 | "composer.lock missing"))
76 | continue
77 |
78 | try:
79 | lock = json.loads(lock_contents)
80 | except ValueError:
81 | results.add_result(path, TestResult(
82 | Result.SKIP, "composer.lock cannot be parsed"))
83 | continue
84 |
85 | checker_found = False
86 | for package in lock.get('packages', []):
87 | if not isinstance(package, dict):
88 | continue
89 | if package.get('name') == 'sensiolabs/security-checker':
90 | checker_found = True
91 | break
92 |
93 | if not checker_found:
94 | results.add_result(path, TestResult(
95 | Result.SKIP,
96 | "sensiolabs/security-checker is not installed, cannot proceed"
97 | ))
98 | continue
99 |
100 | security_checker = _find_checker(path)
101 | if not security_checker:
102 | results.add_result(path, TestResult(
103 | Result.SKIP, "cannot find security-checker to execute"))
104 | continue
105 |
106 | try:
107 | proc = subprocess.Popen([
108 | security_checker, 'security:check', '--no-ansi', '--format',
109 | 'json', '-n', path],
110 | stdout=subprocess.PIPE)
111 | (output, _) = proc.communicate()
112 | except (subprocess.CalledProcessError, OSError):
113 | results.add_result(path, TestResult(Result.FAIL,
114 | "checker failed to run"))
115 | continue
116 |
117 | try:
118 | issues = json.loads(output.decode('utf-8', errors='replace'))
119 | except ValueError:
120 | results.add_result(path, TestResult(
121 | Result.FAIL, "cannot parse checker's response"))
122 | continue
123 |
124 | if issues:
125 | results.add_result(path, TestResult(
126 | Result.FAIL,
127 | "%s: module has known vulnerabilities" % ', '.join(issues)))
128 | else:
129 | results.add_result(path, TestResult(Result.PASS))
130 |
131 | return results
132 |
133 |
134 | def _find_all_inis(config_set):
135 | if not config_set:
136 | return []
137 |
138 | found = []
139 |
140 | # the first item should be just the main ini
141 | if os.path.isfile(config_set['ini_file']):
142 | found.append(config_set['ini_file'])
143 | else:
144 | logger.warning('expected "%s" to be a file, ignoring',
145 | config_set['ini_file'])
146 |
147 | scan_dirs = config_set['ini_dirs'].split(':')
148 | for scan_dir in scan_dirs:
149 | if not os.path.isdir(scan_dir):
150 | continue
151 |
152 | for entry in sorted(os.listdir(scan_dir)):
153 | if not entry.endswith('.ini'):
154 | continue
155 |
156 | full_path = os.path.join(scan_dir, entry)
157 | if not os.path.isfile(full_path):
158 | continue
159 |
160 | found.append(full_path)
161 |
162 | return found
163 |
164 |
165 | def _parse_php_config(path, config):
166 | # php ini files are nothing like ini files, they need special treatment
167 | # like skipping sections and converting values to booleans
168 | with open(path, 'r') as f:
169 | lines = f.readlines()
170 |
171 | for line in lines:
172 | line = line.strip()
173 | if not line:
174 | continue
175 | if line.startswith(';'): # skip comment
176 | continue
177 | if line.startswith('['): # skip sections... because php
178 | continue
179 |
180 | key, _, val = line.partition('=')
181 | key = key.strip()
182 | val = val.strip()
183 | if not key:
184 | logger.warning('line "%s" is invalid php config, skipped', line)
185 | continue
186 |
187 | if not val:
188 | config[key] = None
189 |
190 | elif val == 'None':
191 | config[key] = None
192 |
193 | elif val in ('1', 'On', 'True', 'Yes'):
194 | config[key] = True
195 |
196 | elif val in ('0', 'Off', 'False', 'No'):
197 | config[key] = False
198 |
199 | elif val[0] == '"' and val[-1] == '"':
200 | config[key] = val[1:-1]
201 |
202 | else:
203 | config[key] = val
204 |
205 | return config
206 |
207 |
208 | def _conf_ini_paths():
209 | options = {
210 | 'allow_url_fopen': {'allowed': [False]},
211 | 'allow_url_include': {'disallowed': [True]},
212 | 'display_errors': {'allowed': [False, 'stderr']},
213 | 'expose_php': {'disallowed': [True]},
214 | 'open_basedir': {'allowed': "*"},
215 | }
216 | return {
217 | 'cli': {
218 | 'ini_file': '/etc/php/7.0/cli/php.ini',
219 | 'ini_dirs': '/etc/php/7.0/cli/conf.d',
220 | 'options': options,
221 | },
222 | 'cgi': {
223 | 'ini_file': '/etc/php/7.0/cgi/php.ini',
224 | 'ini_dirs': '/etc/php/7.0/cgi/conf.d',
225 | 'options': options,
226 | },
227 | 'fpm': {
228 | 'ini_file': '/etc/php/7.0/fpm/php.ini',
229 | 'ini_dirs': '/etc/php/7.0/fpm/conf.d',
230 | 'options': options,
231 | },
232 | }
233 |
234 |
235 | @test_class.explanation("""
236 | Protection name: Protections in the php configuration
237 |
238 | Check: Validates known security-related options in PHP
239 | configuration.
240 |
241 | Purpose: Some options in the php.ini configs apply to
242 | most applications. This check verifies both that some
243 | options are turned off and that others are left as
244 | defaults. With the default config, the following options
245 | are checked:
246 | - allow_url_fopen: make sure fopen is only allowed to
247 | local files
248 | - allow_url_include: make sure remote code files cannot
249 | be included
250 | - display_errors: don't show php errors to page users
251 | - expose_php: don't expose php version - while it doesn't
252 | improve security it prevents the site from
253 | being indexed for future exploitation
254 | - open_basedir: only files within specified directories
255 | should be possible to open by the php
256 | application
257 | """)
258 | @test_class.takes_config(_conf_ini_paths)
259 | def php_ini(ini_paths):
260 | results = GroupTestResult()
261 |
262 | for set_name, config_set in ini_paths.items():
263 | inis = _find_all_inis(config_set)
264 | if not inis:
265 | logger.warning('no php config paths found for %s', set_name)
266 | results.add_result(set_name, TestResult(Result.SKIP,
267 | 'config not found'))
268 | continue
269 |
270 | config = {}
271 | for ini in inis:
272 | config = _parse_php_config(ini, config)
273 |
274 | for test, res in utils.verify_config(
275 | set_name, config, config_set['options'], needs_parsing=False):
276 | results.add_result(test, res)
277 |
278 | return results
279 |
--------------------------------------------------------------------------------
/reconbf/modules/test_stunnel.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Hewlett Packard Enterprise Development LP
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | from reconbf.lib import test_class
16 | from reconbf.lib import utils
17 | from reconbf.lib.logger import logger
18 | from reconbf.lib.result import GroupTestResult
19 | from reconbf.lib.result import Result
20 | from reconbf.lib.result import TestResult
21 |
22 | import collections
23 | import glob
24 | import os
25 |
26 |
27 | def _do_read_config(path, config=collections.defaultdict(dict), section=None):
28 | with open(path, 'r') as f:
29 | conf_lines = f.readlines()
30 |
31 | for lineno, line in enumerate(conf_lines):
32 | # strip comment
33 | try:
34 | comment_start = line.rindex(';')
35 | except ValueError:
36 | pass # no comment found
37 | else:
38 | line = line[:comment_start]
39 |
40 | line = line.strip()
41 | if not line:
42 | continue
43 |
44 | if line.startswith('[') and line.endswith(']'):
45 | section = line[1:-1]
46 | continue
47 |
48 | parts = line.split('=', 1)
49 | if len(parts) != 2:
50 | logger.warning("Could not parse line %i in config '%s'",
51 | lineno + 1, path)
52 | continue
53 |
54 | key = parts[0].strip()
55 | val = parts[1].strip()
56 |
57 | if key == 'include':
58 | for d_path in os.listdir(val):
59 | conf_path = os.path.join(d_path, path)
60 | _read_config(conf_path, config, section)
61 |
62 | if key == 'options':
63 | # special case, there can be multiple values
64 | if key in config[section]:
65 | config[section][key].append(val)
66 | else:
67 | config[section][key] = [val]
68 | else:
69 | config[section][key] = val
70 |
71 | return config
72 |
73 |
74 | @utils.idempotent
75 | def _read_config(path):
76 | return _do_read_config(path)
77 |
78 |
79 | def _merge_options(current, new):
80 | for option in new:
81 | if option.startswith('-'):
82 | try:
83 | current.remove(option[1:])
84 | except ValueError:
85 | pass
86 | else:
87 | current.append(option)
88 |
89 |
90 | def _find_bad_options(options, test_config):
91 | bad = []
92 | for opt in test_config.get('enforce', []):
93 | if opt not in options:
94 | bad.append(opt + ' missing')
95 | for opt in test_config.get('forbid', []):
96 | if opt in options:
97 | bad.append(opt + 'forbidden')
98 | return bad
99 |
100 |
101 | def _conf_ssl_options():
102 | return {
103 | 'configs': '/etc/stunnel/*.conf',
104 | 'enforce': ['NO_SSLv2', 'NO_SSLv3', 'NO_COMPRESSION'],
105 | 'forbid': [],
106 | }
107 |
108 |
109 | @test_class.takes_config(_conf_ssl_options)
110 | @test_class.explanation("""
111 | Protection name: Forbid bad SSL options
112 |
113 | Check: Make sure that neither the default configuration nor
114 | any of the server sections allows forbidden options.
115 |
116 | Purpose: Enforces or forbids specific openssl options. These may
117 | prevent/mitigate known vulnerabilities. By default the following
118 | options are enforced:
119 | - NO_SSLv2 (because of DROWN and others)
120 | - NO_SSLv3 (because of POODLE and others)
121 | - NO_COMPRESSION (because of CRIME)
122 | """)
123 | def ssl_options(test_config):
124 | results = GroupTestResult()
125 |
126 | paths = glob.glob(test_config['configs'])
127 | if not paths:
128 | return TestResult(Result.SKIP, "No stunnel config found")
129 |
130 | for path in paths:
131 | config = _read_config(path)
132 | default_options = ['NO_SSLv2', 'NO_SSLv3']
133 | options = config[None].get('options', [])
134 | _merge_options(default_options, options)
135 |
136 | bad_options = _find_bad_options(default_options, test_config)
137 | if not bad_options:
138 | results.add_result('%s:default' % path, TestResult(Result.PASS))
139 | else:
140 | for explanation in bad_options:
141 | results.add_result('%s:default' % path,
142 | TestResult(Result.FAIL, explanation))
143 |
144 | for section in config:
145 | if section is None:
146 | continue
147 | section_options = default_options[:]
148 | options = config[section].get('options', [])
149 | _merge_options(section_options, options)
150 |
151 | bad_options = _find_bad_options(section_options, test_config)
152 | if not bad_options:
153 | results.add_result('%s:%s' % (path, section),
154 | TestResult(Result.PASS))
155 | else:
156 | for explanation in bad_options:
157 | results.add_result('%s:%s' % (path, section),
158 | TestResult(Result.FAIL, explanation))
159 | return results
160 |
161 |
162 | def _conf_bad_ciphers():
163 | return {
164 | 'configs': '/etc/stunnel/*.conf',
165 | 'bad_ciphers': ['DES', 'MD5', 'RC4', 'SEED', 'aNULL', 'eNULL'],
166 | }
167 |
168 |
169 | @test_class.takes_config(_conf_bad_ciphers)
170 | @test_class.explanation("""
171 | Protection name: Forbid known broken and weak protocols
172 |
173 | Check: Make sure that neither the default configuration nor
174 | any of the server sections allows ciphers which are known
175 | to be weak or broken.
176 |
177 | Purpose: OpenSSL comes with ciphers which should not be used
178 | in production. For example MD5 and RC4 algorithms have known
179 | issues when applied in SSL/TLS context. This check will list
180 | all available OpenSSL ciphers and make sure that the configured
181 | ciphers are not allowed.
182 |
183 | For information about a secure string, see
184 | https://hynek.me/articles/hardening-your-web-servers-ssl-ciphers/
185 | """)
186 | def ssl_ciphers(test_config):
187 | paths = glob.glob(test_config['configs'])
188 | if not paths:
189 | return TestResult(Result.SKIP, "No stunnel config found")
190 |
191 | bad_ciphers_desc = ':'.join(test_config['bad_ciphers'])
192 | try:
193 | bad_ciphers = set(utils.expand_openssl_ciphers(bad_ciphers_desc))
194 | except Exception:
195 | return TestResult(Result.SKIP,
196 | "Cannot use openssl to expand cipher list")
197 | results = GroupTestResult()
198 |
199 | for path in paths:
200 | config = _read_config(path)
201 | default_ciphers_desc = config[None].get('ciphers', 'DEFAULT')
202 | default_ciphers = utils.expand_openssl_ciphers(default_ciphers_desc)
203 | failures = ','.join(set(default_ciphers) & bad_ciphers)
204 | test_name = "%s:default" % path
205 | if failures:
206 | msg = "forbidden ciphers: %s" % failures
207 | results.add_result(test_name, TestResult(Result.FAIL, msg))
208 | else:
209 | results.add_result(test_name, TestResult(Result.PASS))
210 |
211 | for section in config:
212 | section_ciphers_desc = config[section].get('ciphers')
213 | if section_ciphers_desc:
214 | section_ciphers = utils.expand_openssl_ciphers(
215 | section_ciphers_desc)
216 | else:
217 | section_ciphers = default_ciphers
218 | failures = ','.join(set(section_ciphers) & bad_ciphers)
219 | test_name = "%s:%s" % (path, section)
220 | if failures:
221 | msg = "forbidden ciphers: %s" % failures
222 | results.add_result(test_name, TestResult(Result.FAIL, msg))
223 | else:
224 | results.add_result(test_name, TestResult(Result.PASS))
225 |
226 | return results
227 |
228 |
229 | def _conf_certificate_check():
230 | return {
231 | 'configs': '/etc/stunnel/*.conf',
232 | }
233 |
234 |
235 | @test_class.takes_config(_conf_certificate_check)
236 | @test_class.explanation("""
237 | Protection name: Check certificates sanity.
238 |
239 | Check: Validate a number of properties of the provided SSL
240 | certificates. This includes the stock openssl verification
241 | as well as custom.
242 |
243 | Purpose: Certificates can be a weak point of an SSL
244 | connection. This check validates some simple properties of
245 | the provided certificate. This includes:
246 | - 'openssl verify' validation
247 | - signature algorithm blacklist
248 | - key size check
249 | """)
250 | def certificate_check(test_config):
251 | paths = glob.glob(test_config['configs'])
252 | if not paths:
253 | return TestResult(Result.SKIP, "No stunnel config found")
254 |
255 | results = GroupTestResult()
256 |
257 | for path in paths:
258 | config = _read_config(path)
259 |
260 | for section in config:
261 | cert_path = config[section].get('cert')
262 | # do this check only on sections with configured certificates
263 | if not cert_path:
264 | continue
265 |
266 | issues = utils.find_certificate_issues(cert_path)
267 | test_name = "%s:%s" % (path, section)
268 | if issues:
269 | msg = "problem in %s: %s" % (cert_path, issues)
270 | results.add_result(test_name, TestResult(Result.FAIL, msg))
271 | else:
272 | results.add_result(test_name, TestResult(Result.PASS))
273 |
274 | return results
275 |
--------------------------------------------------------------------------------
/reconbf/modules/test_cinder.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Hewlett Packard Enterprise Development LP
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | from reconbf.lib import test_class
16 | from reconbf.lib.result import GroupTestResult
17 | from reconbf.lib.result import Result
18 | from reconbf.lib.result import TestResult
19 | from reconbf.lib import utils
20 | import grp
21 | import os
22 | import pwd
23 |
24 |
25 | def _conf_location():
26 | return {'dir': '/etc/cinder'}
27 |
28 |
29 | def _conf_details():
30 | config = _conf_location().copy()
31 | config['user'] = 'root'
32 | config['group'] = 'cinder'
33 | return config
34 |
35 |
36 | @test_class.explanation("""
37 | Protection name: Config permissions
38 |
39 | Check: Are cinder config permissions ok
40 |
41 | Purpose: Cinder config files contain authentication
42 | details and need to be protected. Ensure that
43 | they're only available to the service.
44 | """)
45 | @test_class.set_mapping("OpenStack:Check-Block-01",
46 | "OpenStack:Check-Block-02")
47 | @test_class.takes_config(_conf_details)
48 | def config_permission(config):
49 | try:
50 | user = pwd.getpwnam(config['user'])
51 | except KeyError:
52 | return TestResult(Result.SKIP,
53 | 'Could not find user "%s"' % config['user'])
54 |
55 | try:
56 | group = grp.getgrnam(config['group'])
57 | except KeyError:
58 | return TestResult(Result.SKIP,
59 | 'Could not find group "%s"' % config['group'])
60 |
61 | result = GroupTestResult()
62 | files = ['cinder.conf', 'api-paste.ini', 'policy.json', 'rootwrap.conf']
63 | for f in files:
64 | path = os.path.join(config['dir'], f)
65 | result.add_result(path,
66 | utils.validate_permissions(path, 0o640, user.pw_uid,
67 | group.gr_gid))
68 | return result
69 |
70 |
71 | @test_class.explanation("""
72 | Protection name: Authentication strategy
73 |
74 | Check: Make sure proper authentication is used
75 |
76 | Purpose: There are multiple authentication backends
77 | available. Cinder should be configured to authenticate
78 | against keystone rather than test backends.
79 | """)
80 | @test_class.set_mapping("OpenStack:Check-Block-03")
81 | @test_class.takes_config(_conf_location)
82 | def cinder_auth(config):
83 | try:
84 | path = os.path.join(config['dir'], 'cinder.conf')
85 | cinder_conf = utils.parse_openstack_ini(path)
86 | except EnvironmentError:
87 | return TestResult(Result.SKIP, 'cannot read cinder config files')
88 |
89 | auth = cinder_conf.get('DEFAULT', {}).get('auth_strategy', 'keystone')
90 | if auth != 'keystone':
91 | return TestResult(Result.FAIL,
92 | 'authentication should be done by keystone')
93 | else:
94 | return TestResult(Result.PASS)
95 |
96 |
97 | @test_class.explanation("""
98 | Protection name: Keystone api access
99 |
100 | Check: Does Keystone access use secure connection
101 |
102 | Purpose: OpenStack components communicate with each other
103 | using various protocols and the communication might
104 | involve sensitive / confidential data. An attacker may
105 | try to eavesdrop on the channel in order to get access to
106 | sensitive information. Thus all the components must
107 | communicate with each other using a secured communication
108 | protocol.
109 | """)
110 | @test_class.set_mapping("OpenStack:Check-Block-04")
111 | @test_class.takes_config(_conf_location)
112 | def keystone_secure(config):
113 | try:
114 | path = os.path.join(config['dir'], 'cinder.conf')
115 | cinder_conf = utils.parse_openstack_ini(path)
116 | except EnvironmentError:
117 | return TestResult(Result.SKIP, 'cannot read cinder config files')
118 |
119 | protocol = cinder_conf.get('keystone_authtoken', {}).get('auth_protocol',
120 | 'https')
121 | identity = cinder_conf.get('keystone_authtoken', {}).get('identity_uri',
122 | 'https:')
123 |
124 | if not identity.startswith('https:'):
125 | return TestResult(Result.FAIL, 'keystone access is not secure')
126 | if protocol != 'https':
127 | return TestResult(Result.FAIL, 'keystone access is not secure')
128 |
129 | return TestResult(Result.PASS)
130 |
131 |
132 | @test_class.explanation("""
133 | Protection name: Nova api access
134 |
135 | Check: Does Nova access use secure connection
136 |
137 | Purpose: OpenStack components communicate with each other
138 | using various protocols and the communication might
139 | involve sensitive / confidential data. An attacker may
140 | try to eavesdrop on the channel in order to get access to
141 | sensitive information. Thus all the components must
142 | communicate with each other using a secured communication
143 | protocol.
144 | """)
145 | @test_class.set_mapping("OpenStack:Check-Block-05")
146 | @test_class.takes_config(_conf_location)
147 | def nova_secure(config):
148 | try:
149 | path = os.path.join(config['dir'], 'cinder.conf')
150 | cinder_conf = utils.parse_openstack_ini(path)
151 | except EnvironmentError:
152 | return TestResult(Result.SKIP, 'cannot read cinder config files')
153 |
154 | insecure = cinder_conf.get('DEFAULT', {}).get(
155 | 'nova_api_insecure', 'False').lower() == 'true'
156 |
157 | if insecure:
158 | return TestResult(Result.FAIL, 'nova access is not secure')
159 | else:
160 | return TestResult(Result.PASS)
161 |
162 |
163 | @test_class.explanation("""
164 | Protection name: Glance api access
165 |
166 | Check: Does Glance access use secure connection
167 |
168 | Purpose: OpenStack components communicate with each other
169 | using various protocols and the communication might
170 | involve sensitive / confidential data. An attacker may
171 | try to eavesdrop on the channel in order to get access to
172 | sensitive information. Thus all the components must
173 | communicate with each other using a secured communication
174 | protocol.
175 | """)
176 | @test_class.set_mapping("OpenStack:Check-Block-06")
177 | @test_class.takes_config(_conf_location)
178 | def glance_secure(config):
179 | try:
180 | path = os.path.join(config['dir'], 'cinder.conf')
181 | cinder_conf = utils.parse_openstack_ini(path)
182 | except EnvironmentError:
183 | return TestResult(Result.SKIP, 'cannot read cinder config files')
184 |
185 | insecure = cinder_conf.get('DEFAULT', {}).get(
186 | 'glance_api_insecure', 'False').lower() == 'true'
187 |
188 | if insecure:
189 | return TestResult(Result.FAIL, 'glance access is not secure')
190 | else:
191 | return TestResult(Result.PASS)
192 |
193 |
194 | @test_class.explanation("""
195 | Protection name: Strategy for NAS file storage
196 |
197 | Check: Are strict permissions enforced on NAS storage
198 |
199 | Purpose: NAS volume files can be stored either with root
200 | or non-root ownership and with open or strict permissions.
201 | Report on both of those settings.
202 | """)
203 | @test_class.set_mapping("OpenStack:Check-Block-07")
204 | @test_class.takes_config(_conf_location)
205 | def nas_security(config):
206 | try:
207 | path = os.path.join(config['dir'], 'cinder.conf')
208 | cinder_conf = utils.parse_openstack_ini(path)
209 | except EnvironmentError:
210 | return TestResult(Result.SKIP, 'cannot read cinder config files')
211 |
212 | secure_operations = cinder_conf.get('DEFAULT', {}).get(
213 | 'nas_secure_file_operations', 'auto').lower() != 'false'
214 | secure_permissions = cinder_conf.get('DEFAULT', {}).get(
215 | 'nas_secure_file_permissions', 'auto').lower() != 'false'
216 |
217 | results = GroupTestResult()
218 |
219 | if secure_operations:
220 | results.add_result('operations', TestResult(Result.PASS))
221 | else:
222 | results.add_result('operations', TestResult(
223 | Result.FAIL, 'NAS operations are not secure'))
224 |
225 | if secure_permissions:
226 | results.add_result('permissions', TestResult(Result.PASS))
227 | else:
228 | results.add_result('permissions', TestResult(
229 | Result.FAIL, 'NAS permissions are not secure'))
230 |
231 | return results
232 |
233 |
234 | @test_class.explanation("""
235 | Protection name: Body size limit
236 |
237 | Check: Ensure large requests are stopped.
238 |
239 | Purpose: Large requests can cause a denial of service.
240 | Setting up a limit ensures that they're rejected without
241 | full processing.
242 | """)
243 | @test_class.set_mapping("OpenStack:Check-Block-08")
244 | @test_class.takes_config(_conf_location)
245 | def body_size(config):
246 | try:
247 | path = os.path.join(config['dir'], 'cinder.conf')
248 | cinder_conf = utils.parse_openstack_ini(path)
249 | except EnvironmentError:
250 | return TestResult(Result.SKIP, 'cannot read cinder config files')
251 |
252 | osapi_max_body_size = int(cinder_conf.get('DEFAULT', {}).get(
253 | 'osapi_max_request_body_size', '114688'))
254 | oslo_max_body_size = int(cinder_conf.get('oslo_middleware', {}).get(
255 | 'max_request_body_size', '114688'))
256 |
257 | results = GroupTestResult()
258 |
259 | res_name = 'osapi body size'
260 | if osapi_max_body_size <= 114688:
261 | results.add_result(res_name, TestResult(Result.PASS))
262 | else:
263 | results.add_result(res_name, TestResult(
264 | Result.FAIL, 'osapi allows too big request bodies'))
265 |
266 | res_name = 'oslo body size'
267 | if oslo_max_body_size <= 114688:
268 | results.add_result(res_name, TestResult(Result.PASS))
269 | else:
270 | results.add_result(res_name, TestResult(
271 | Result.FAIL, 'middleware allows too big request bodies'))
272 |
273 | return results
274 |
--------------------------------------------------------------------------------
/reconbf/modules/test_users.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Hewlett Packard Enterprise Development LP
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | from reconbf.lib.logger import logger
16 | import reconbf.lib.test_class as test_class
17 | from reconbf.lib.result import Result
18 | from reconbf.lib.result import TestResult
19 | from reconbf.lib import utils
20 |
21 | from collections import defaultdict
22 | import grp
23 | import pwd
24 | import subprocess
25 |
26 |
27 | @test_class.explanation(
28 | """
29 | Protection name: Accounts with no password
30 |
31 | Check: Lists how many accounts are lockerd, disabled, and have no password
32 | set. Will fail for, and list, any accounts which are found with no
33 | password.
34 |
35 | Purpose: Every user account should either have a password set or be
36 | disabled. One common way for attackers to gain access to a system is to
37 | enumerate typical user accounts and try to log in with usual credentials,
38 | or even without credentials. By ensuring all accounts have passwords or
39 | are disabled, it makes it harder for an attacker to gain a foothold in the
40 | system.
41 | """)
42 | def test_accounts_nopassword():
43 | try:
44 | import spwd
45 | except ImportError:
46 | logger.info("Import of spwd failed ")
47 | return TestResult(Result.SKIP, "Unable to import 'spwd' module")
48 |
49 | disabled = []
50 | locked = []
51 | passworded = []
52 | no_password = []
53 |
54 | shadow_entries = spwd.getspall()
55 |
56 | for entry in shadow_entries:
57 | # passwords which start with ! have been locked
58 | if entry.sp_pwd.startswith('!'):
59 | locked.append(entry.sp_nam)
60 | # passwords which start with * have been disabled
61 | elif entry.sp_pwd.startswith('*'):
62 | disabled.append(entry.sp_nam)
63 | # blank passwords are bad!
64 | elif entry.sp_pwd == "":
65 | no_password.append(entry.sp_nam)
66 | # otherwise the account has a password
67 | else:
68 | passworded.append(entry.sp_nam)
69 |
70 | if len(no_password) > 0:
71 | notes = "Account(s) { " + str(no_password) + " } have no password!"
72 | test_result = Result.FAIL
73 | else:
74 | notes = ("Disabled: " + str(len(disabled)) + ", Locked: " +
75 | str(len(locked)) + ", Password: " + str(len(passworded)) +
76 | ", No Password: " + str(len(no_password)))
77 | test_result = Result.PASS
78 |
79 | return TestResult(test_result, notes)
80 |
81 |
82 | @test_class.explanation(
83 | """
84 | Protection name: List sudoers
85 |
86 | Check: Lists all users that are lister in sudoers. Fail if any of the
87 | users have sudo access with NOPASSWD.
88 |
89 | Purpose: Sudoers can provide a path for privilege escalation. It is very
90 | important to keep close track of which users have sudo privileges. In
91 | particular, users which have sudo privilege without requiring a password
92 | (NOPASSWD), can provide attackers with an easy path to obtain root level
93 | access to a system.
94 | """)
95 | def test_list_sudoers():
96 | if not utils.have_command('sudo'):
97 | return TestResult(Result.SKIP, "sudo not installed")
98 |
99 | # these can be moved to config if there is a good reason somebody would
100 | # ever want to change them, for now they stay here
101 | list_sudoer_command = ['sudo', '-U', '$USER', '-l']
102 |
103 | not_sudo_string = b'not allowed to run sudo'
104 | sudo_string = b'may run the following commands'
105 | nopasswd_string = b'NOPASSWD'
106 |
107 | passwd_entries = pwd.getpwall()
108 |
109 | user_accounts = []
110 | for entry in passwd_entries:
111 | if entry.pw_name != 'root':
112 | user_accounts.append(entry.pw_name)
113 |
114 | sudo_users = []
115 | nopasswd_users = []
116 |
117 | for user in user_accounts:
118 | # set the user in the sudo command template
119 | list_sudoer_command[2] = user
120 | proc = subprocess.Popen(list_sudoer_command, stdout=subprocess.PIPE)
121 | (output, _stderr) = proc.communicate()
122 |
123 | # if the output has the non-sudo user string in it, do nothing
124 | if not_sudo_string in output:
125 | pass
126 | # otherwise...
127 | elif sudo_string in output:
128 | # if NOPASSWD tag is found
129 | if nopasswd_string in output:
130 | nopasswd_users.append(user)
131 | # sudo user that requires a password
132 | else:
133 | sudo_users.append(user)
134 |
135 | # fail if there are NOPASSWD sudo users
136 | if len(nopasswd_users) > 0:
137 | result = Result.FAIL
138 | notes = "User(s) { " + str(nopasswd_users) + " } have password-less "
139 | notes += "sudo access!"
140 | # otherwise the test passes
141 | else:
142 | result = Result.PASS
143 | if len(sudo_users) > 0:
144 | notes = "User(s) { " + str(sudo_users) + " } have sudo access"
145 | else:
146 | notes = "No users have sudo access"
147 |
148 | return TestResult(result, notes)
149 |
150 |
151 | @test_class.explanation(
152 | """
153 | Protection name: Unique user names and IDs
154 |
155 | Check: The user name (1st item in each passwd entry), and the user ID
156 | (3rd item in each passwd entry) are unique (don't appear anywhere else in
157 | the /etc/passwd file).
158 |
159 | Purpose: Users in *nix systems are identified within the system by user ID
160 | (UID). These should be unique for each user to prevent unintended
161 | consequences, such as granting access to a resource for an unexpected user.
162 | It is particularly important that the root user is the only user on the
163 | system with UID 0.
164 | """)
165 | def test_unique_user():
166 | passwd_entries = pwd.getpwall()
167 | uids = defaultdict(list)
168 | user_names = defaultdict(list)
169 |
170 | # create dict of user IDs for user names and user names for user IDs
171 | for entry in passwd_entries:
172 | # add the user to the list of users for that UID
173 | uids[entry.pw_uid].append(entry.pw_name)
174 |
175 | # add the user to the list of UIDs for that username
176 | user_names[entry.pw_name].append(entry.pw_uid)
177 |
178 | notes = ''
179 | result = Result.PASS
180 |
181 | # ensure UID is unique, fail if UID 0 is not
182 | for uid in uids.keys():
183 | # if there are more than one user with this UID
184 | if len(uids[uid]) > 1:
185 | # if the duplicated UID is for root, the test fails
186 | if uid == 0:
187 | result = Result.FAIL
188 | if notes != '':
189 | notes += ', '
190 | # regardless, add to the notes that there are multiple users with
191 | # this UID
192 | notes += 'Users { ' + str(uids[uid]) + " } have UID " + str(uid)
193 |
194 | # ensure username is unique, fail if root is not
195 | for username in user_names.keys():
196 | # if there are more than one user with this user name
197 | if len(user_names[username]) > 1:
198 | # if the duplicated user name is root, the test fails
199 | if username == 'root':
200 | result = Result.FAIL
201 | if notes != '':
202 | notes += ', '
203 | # regardless, add to the notes that there are multiple users with
204 | # this name
205 | notes += 'UIDs { ' + str(user_names[username]) + " } have name "
206 | notes += username
207 |
208 | if notes == '':
209 | notes = "No users have same UID or name"
210 |
211 | return TestResult(result, notes)
212 |
213 |
214 | @test_class.explanation(
215 | """
216 | Protection name: Unique group names and IDs
217 |
218 | Check: The group name (1st item in each group entry), and the group ID
219 | (3rd item in each group entry) are unique (don't appear anywhere else in
220 | the /etc/group file).
221 |
222 | Purpose: Groups in *nix systems are identified within the system by the
223 | Group ID (GID). They are identified by their group name by end users. To
224 | avoid granting access to unintended groups, both the group name and group
225 | ID should be unique for each group.
226 | """)
227 | def test_unique_group():
228 | grp_entries = grp.getgrall()
229 | gids = defaultdict(list)
230 | group_names = defaultdict(list)
231 |
232 | # create dict of group IDs for group names and group names for group IDs
233 | for entry in grp_entries:
234 | # add the group to the list of groups for that GID
235 | gids[entry.gr_gid].append(entry.gr_name)
236 |
237 | # add the group to the list of GIDs for that group
238 | group_names[entry.gr_name].append(entry.gr_gid)
239 |
240 | notes = ''
241 | result = Result.PASS
242 |
243 | # ensure GID is unique, fail if GID 0 is not
244 | for gid in gids.keys():
245 | # if there are more than one group with this GID
246 | if len(gids[gid]) > 1:
247 | # if the duplicated GID is for root, the test fails
248 | if gid == 0:
249 | result = Result.FAIL
250 | if notes != '':
251 | notes += ', '
252 | # regardless, add to the notes that there are multiple groups with
253 | # this GID
254 | notes += 'Groups { ' + str(gids[gid]) + " } have GID " + str(gid)
255 |
256 | # ensure group is unique, fail if root is not
257 | for groupname in group_names.keys():
258 | # if there are more than one group with this group name
259 | if len(group_names[groupname]) > 1:
260 | # if the duplicated group name is root, the test fails
261 | if groupname == 'root':
262 | result = Result.FAIL
263 | if notes != '':
264 | notes += ', '
265 | # regardless, add to the notes that there are multiple groups with
266 | # this name
267 | notes += 'GIDs { ' + str(group_names[groupname]) + " } have name "
268 | notes += groupname
269 |
270 | if notes == '':
271 | notes = "No groups have same GID or name"
272 |
273 | return TestResult(result, notes)
274 |
--------------------------------------------------------------------------------