├── .gitignore ├── .travis.yml ├── LICENSE.txt ├── README.rst ├── config ├── default.cfg ├── docker.cfg ├── hos.cfg ├── rbf.cfg └── signing.cfg ├── reconbf ├── __init__.py ├── __main__.py ├── lib │ ├── __init__.py │ ├── config.py │ ├── constants.py │ ├── logger.py │ ├── result.py │ ├── test_class.py │ └── utils.py ├── modules │ ├── __init__.py │ ├── test_access.py │ ├── test_binaries.py │ ├── test_cinder.py │ ├── test_dns.py │ ├── test_docker.py │ ├── test_file_controls.py │ ├── test_firewall.py │ ├── test_haproxy.py │ ├── test_hardware.py │ ├── test_horizon.py │ ├── test_kernel.py │ ├── test_keystone.py │ ├── test_mac.py │ ├── test_manila.py │ ├── test_mem.py │ ├── test_mounts.py │ ├── test_mysql.py │ ├── test_neutron.py │ ├── test_nginx.py │ ├── test_nova.py │ ├── test_package_support.py │ ├── test_php.py │ ├── test_sec.py │ ├── test_secureboot.py │ ├── test_services.py │ ├── test_signing.py │ ├── test_stunnel.py │ ├── test_upgrades.py │ └── test_users.py └── templates │ └── results_template.html ├── scripts ├── hos_v1 └── test_script ├── setup.cfg ├── setup.py ├── test-requirements.txt ├── tests ├── __init__.py ├── test_cinder.py ├── test_config.py ├── test_generate_config.py ├── test_horizon.py ├── test_kernel.py ├── test_manila.py ├── test_neutron.py ├── test_nginx.py └── test_sec.py └── tox.ini /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /config/signing.cfg: -------------------------------------------------------------------------------- 1 | { 2 | "modules": { 3 | "test_secureboot": {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /reconbf/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HewlettPackard/reconbf/bfd15bef549f011a3de885c3267d4f718223b798/reconbf/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /reconbf/lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HewlettPackard/reconbf/bfd15bef549f011a3de885c3267d4f718223b798/reconbf/lib/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /reconbf/modules/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HewlettPackard/reconbf/bfd15bef549f011a3de885c3267d4f718223b798/reconbf/modules/__init__.py -------------------------------------------------------------------------------- /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_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_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/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_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 | -------------------------------------------------------------------------------- /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_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_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_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 | -------------------------------------------------------------------------------- /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_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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_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_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 | -------------------------------------------------------------------------------- /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_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_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/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_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_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_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 | -------------------------------------------------------------------------------- /reconbf/templates/results_template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
6 | 7 | 8 |
9 |Test Name | 16 |Result | 17 |Notes | 18 |
---|