├── .gitignore ├── .pep8speaks.yml ├── DESIGN_NOTES.md ├── LICENSE ├── README.md ├── bin ├── oscapd ├── oscapd-cli └── oscapd-evaluate ├── container ├── config.ini ├── help.sh ├── install.sh ├── openscap ├── remediate.py └── run.sh ├── generate-dockerfile.py ├── man ├── oscapd-cli.8 ├── oscapd-evaluate.8 └── oscapd.8 ├── openscap_daemon ├── __init__.py ├── async_tools.py ├── cli_helpers.py ├── compat.py ├── config.py ├── cve_feed_manager.py ├── cve_scanner │ ├── __init__.py │ ├── applicationconfiguration.py │ ├── cve_scanner.py │ ├── generate_summary.py │ ├── image_scanner_client.py │ ├── reporter.py │ ├── scan.py │ ├── scanner_client.py │ └── scanner_error.py ├── dbus_daemon.py ├── dbus_utils.py ├── et_helpers.py ├── evaluation_spec.py ├── oscap_helpers.py ├── rest_api.py ├── system.py ├── task.py └── version.py ├── org.oscapd.conf ├── oscapd.service ├── perform-static-analysis ├── pylint.cfg ├── runwrapper.sh ├── setup.py └── tests ├── data_dir_template ├── config.ini ├── config_test.ini └── tasks │ └── 1.xml ├── install_test ├── integration ├── make_check ├── test_oscapd_cli_standalone.sh ├── test_oscapd_evaluate_standalone.sh └── test_task_management.sh ├── make_check ├── testing_data ├── evaluation_spec_cve_scan.xml ├── evaluation_spec_oval.xml ├── evaluation_spec_sds.xml └── ssg-fedora-ds.xml └── unit ├── __init__.py ├── make_check ├── test_basic_update.py ├── test_config.py ├── test_generate_guide.py ├── test_generate_report.py ├── test_serialization.py └── unit_test_harness.py /.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 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | MANIFEST 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | 47 | # Translations 48 | *.mo 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # PyBuilder 58 | target/ 59 | 60 | # vim rope 61 | .ropeproject/ 62 | 63 | # IntelliJ IDEA 64 | .idea/ 65 | 66 | /static-analysis-output 67 | /tests/data_dir_template/cve_feeds/ 68 | /tests/data_dir_template/results/ 69 | -------------------------------------------------------------------------------- /.pep8speaks.yml: -------------------------------------------------------------------------------- 1 | pycodestyle: 2 | max-line-length: 99 3 | -------------------------------------------------------------------------------- /DESIGN_NOTES.md: -------------------------------------------------------------------------------- 1 | # Design Notes 2 | 3 | ## Puzzle Pieces 4 | 5 | ### Task 6 | * target 7 | * host 8 | * VM $URL 9 | * container / image $ID 10 | * input content 11 | * tailoring 12 | * profile id, datastream id, ... 13 | * HTML guide can be always generated 14 | 15 | ### Task Result 16 | * ARF always 17 | * HTML report can be always generated 18 | 19 | 20 | ## CLI use-cases 21 | ``` 22 | $ oscapd-cli task list 23 | 24 | Active tasks: 25 | 26 | ID | Title | Next run | Repeats | 27 | ------------------------------------------------------------------------- 28 | 2 | Weekly USGCB evaluation | 2015-04-10 01:00 (in 8 hours) | @weekly | 29 | 3 | Daily STIG evaluation | 2015-03-09 23:00 (in 6 hours) | @daily | 30 | 4 | One-off evaluation | 2015-03-09 23:30 (in 6 hours) | - | 31 | 32 | Inactive tasks: 33 | 34 | ID | Title 35 | ------------------------------------------------------------------------ 36 | 1 | Testing evaluation 37 | ``` 38 | 39 | ``` 40 | $ oscapd-cli task 2 41 | 42 | ID: 2 43 | Title: Weekly USGCB evaluation 44 | 45 | Target: localhost 46 | Input file: /usr/share/xml/scap/ssg/content/ssg-rhel6-ds.xml 47 | Tailoring: N/A 48 | Profile ID: xccdf_org.ssgproject.content_profile_usgcb-rhel6-server 49 | 50 | Next run: 2015-04-10 01:00 (in 8 hours) 51 | Repeats: @weekly = 168 hours 52 | Time slip: no_slip 53 | 54 | ARF upload: disabled 55 | One-off: false 56 | 57 | Results: 58 | 59 | ID | Timestamp 60 | ---------------------- 61 | 23 | 2015-04-03 01:13 62 | 14 | 2015-03-27 01:11 63 | 11 | 2015-03-20 01:15 64 | ```` 65 | 66 | ``` 67 | $ oscapd-cli result 23 68 | 69 | ID: 23 70 | Task ID: 2 71 | Timestamp: 2015-04-03 72 | ARF path: /var/lib/oscapd-cli/results/23/results-arf.xml 73 | ``` 74 | 75 | ``` 76 | $ oscapd-cli result 23 report > report.html 77 | ``` 78 | ``` 79 | $ oscapd-cli result 23 arf > arf.xml 80 | ``` 81 | ``` 82 | # generate report of last result from task 2 83 | $ oscapd-cli result 2/last report 84 | ``` 85 | ``` 86 | $ oscapd-cli task 2 disable 87 | $ oscapd-cli task 2 enable 88 | ``` 89 | 90 | ``` 91 | # manually update oscapd-cli, for debugging purposes 92 | $ oscapd-cli update 93 | 94 | Found 4 tasks in total, 3 enabled tasks. 95 | ```` 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenSCAP-daemon 2 | > Continuously evaluate your infrastructure for *SCAP* compliance! 3 | > Avoid copying big SCAP files around, avoid having to type long IDs, avoid 4 | > writing ad-hoc bash scripts to solve your compliance needs! 5 | 6 | ## Project Description 7 | OpenSCAP-daemon is a service that performs SCAP scans of bare-metal machines, 8 | virtual machines and containers. These scans can be either one-shot or 9 | continuous according to a schedule. You can interact with the service 10 | using the provided oscapd-cli tool or via the DBus interface. 11 | 12 | ## Motivation 13 | The [OpenSCAP](http://open-scap.org) project has progressed greatly over the 14 | past years and now provides very nice tooling to perform solicited one-off 15 | *SCAP* evaluation of the machine it runs on. Unsolicited, continuous or 16 | planned evaluation has always been out of scope of *OpenSCAP* to avoid feature 17 | creep. The previously mentioned use-case is very desirable and has been 18 | requested many times. We feel that now the time is right to start a project 19 | that **helps you run oscap** and **does evaluation for you**. *OpenSCAP-daemon* 20 | is such a project. 21 | 22 | The project currently comprises of two parts, the **daemon** that runs in the 23 | background sleeping until a task needs processing, and the **command-line tool** 24 | that talks to the aforementioned daemon using *dbus*. Do not be alarmed, the 25 | **command-line tool** is much easier to use than pure `oscap` for common 26 | use-cases. 27 | 28 | ## Features 29 | * *SCAP* evaluation of the following assets using 30 | [OpenSCAP](http://open-scap.org) -- a **NIST-certified** scanner 31 | * **local machine** -- `oscap` 32 | * **remote machine** -- `oscap-ssh` 33 | * **virtual machine** -- `oscap-vm` 34 | * **container** -- `oscap-docker` 35 | * flexible task definition and planning 36 | * use any valid *SCAP* content -- for example 37 | [SCAP Security Guide](http://github.com/OpenSCAP/scap-security-guide), 38 | [NIST USGCB](http://usgcb.nist.gov/), or even 39 | [RHSA OVAL](https://www.redhat.com/security/data/oval/) 40 | * evaluate *daily*, *weekly*, *monthly* or in custom intervals 41 | * evaluate on demand 42 | * parallel task processing 43 | * results storage -- query ARFs of past results, generate HTML reports, get 44 | `oscap` stdout/stderr and exit codes 45 | * command-line interface 46 | * *dbus* *API* 47 | * fully automated CVE evaluation of containers using OpenSCAP and Atomic.mount 48 | * *Cockpit* integration (planned) 49 | 50 | ## Key Goals & Design Decisions 51 | We have learned many important lessons when developing the lower layers of the 52 | *SCAP* evaluation stack that we want to address in this project. 53 | 54 | - **useful defaults** -- just pressing *Enter* and not providing any details 55 | should still yield a valid setup 56 | - **simplicity** -- we avoid *RDBMS* and instead use features of the filesystem 57 | - **datastreams** -- *SDS* (source datastream) and *ARF* (results datastream) 58 | are both used as primary data formats for maximum compatibility between 59 | various tools 60 | - **interactive CLI** -- the CLI should be as interactive as possible, user 61 | shouldn't need to type any IDs or other lengthy options 62 | 63 | ## Example Use-Cases 64 | 65 | ### Scan a container or container image on Atomic Host 66 | Atomic host can use the functionality in OpenSCAP-Daemon to perform vulnerability 67 | scans of containers and container images using the `atomic scan` command. 68 | 69 | To use this functionality, install atomic. Then install openscap-daemon either 70 | in standalone mode or as a SPC container image. When the daemon is running 71 | the `atomic scan` functionality is available. 72 | 73 | ### Scan all containers or all container images on Atomic Host 74 | The `atomic scan` command has command-line arguments --images, --containers and 75 | --all that scan all images, all container and everything respectively. 76 | 77 | ### Scan local machine every day at 1:00 AM UTC 78 | OpenSCAP-daemon thinks in terms of tasks. Let us first define the task we want 79 | to perform: 80 | ```bash 81 | # interactively create a new task 82 | oscapd-cli task-create -i 83 | Creating new task in interactive mode 84 | Title: Daily USGCB 85 | Target (empty for localhost): 86 | Found the following SCAP Security Guide content: 87 | 1: /usr/share/xml/scap/ssg/content/ssg-fedora-ds.xml 88 | 2: /usr/share/xml/scap/ssg/content/ssg-firefox-ds.xml 89 | 3: /usr/share/xml/scap/ssg/content/ssg-java-ds.xml 90 | 4: /usr/share/xml/scap/ssg/content/ssg-rhel6-ds.xml 91 | 5: /usr/share/xml/scap/ssg/content/ssg-rhel7-ds.xml 92 | Choose SSG content by number (empty for custom content): 4 93 | Tailoring file (absolute path, empty for no tailoring): 94 | Found the following possible profiles: 95 | 1: CSCF RHEL6 MLS Core Baseline (id='xccdf_org.ssgproject.content_profile_CSCF-RHEL6-MLS') 96 | 2: United States Government Configuration Baseline (USGCB) (id='xccdf_org.ssgproject.content_profile_usgcb-rhel6-server') 97 | 3: Common Profile for General-Purpose Systems (id='xccdf_org.ssgproject.content_profile_common') 98 | 4: PCI-DSS v3 Control Baseline for Red Hat Enterprise Linux 6 (id='xccdf_org.ssgproject.content_profile_pci-dss') 99 | 5: Example Server Profile (id='xccdf_org.ssgproject.content_profile_CS2') 100 | 6: C2S for Red Hat Enterprise Linux 6 (id='xccdf_org.ssgproject.content_profile_C2S') 101 | 7: Common Profile for General-Purpose SystemsUpstream STIG for RHEL 6 Server (id='xccdf_org.ssgproject.content_profile_stig-rhel6-server-upstream') 102 | 8: Common Profile for General-Purpose SystemsServer Baseline (id='xccdf_org.ssgproject.content_profile_server') 103 | 9: Red Hat Corporate Profile for Certified Cloud Providers (RH CCP) (id='xccdf_org.ssgproject.content_profile_rht-ccp') 104 | Choose profile by number (empty for (default) profile): 2 105 | Online remediation (1, y or Y for yes, else no): 106 | Schedule: 107 | - not before (YYYY-MM-DD HH:MM in UTC, empty for NOW): 2014-07-30 01:00 108 | - repeat after (hours or @daily, @weekly, @monthly, empty or 0 for no repeat): @daily 109 | Task created with ID '1'. It is currently set as disabled. You can enable it with `oscapd-cli task 1 enable`. 110 | ``` 111 | As the command-line interface suggests, we need to enable the task. 112 | ```bash 113 | # enable previously created task of given ID 114 | oscapd-cli task 1 enable 115 | ``` 116 | We may also want to see the HTML guide of our specified task to confirm it will do what we need. 117 | ```bash 118 | # get the HTML guide of task of ID 1 119 | oscapd-cli task 1 guide > guide.html 120 | # open the guide in firefox 121 | firefox guide.html 122 | ``` 123 | At this point `oscapd` will evaluate the local machine at `1:00 AM UTC` every 124 | day and store all the results. To finish this use-case, lets see how we can 125 | query the results after a week of evaluations. 126 | ```bash 127 | # list all available results of task 1 128 | $ oscapd-cli result 1 129 | 7 130 | 6 131 | 5 132 | 4 133 | 3 134 | 2 135 | 1 136 | # get the verbatim results ARF of the 4th result of task 1 137 | oscapd-cli result 1 4 arf > exported-arf.xml 138 | # get the HTML report of previously mentioned result 139 | oscapd-cli result 1 4 report > report.html 140 | # open the report in firefox 141 | firefox report.html 142 | ``` 143 | 144 | ### Solicited evaluation 145 | Sometimes we may want to run the evaluation outside the schedule for testing 146 | or other purposes. The task may even be scheduled to never run automatically! 147 | Such tasks are sometimes necessary. 148 | 149 | ```bash 150 | # run task of ID 1 immediately 151 | oscapd-cli task 1 run 152 | # query available results 153 | oscapd-cli result 1 154 | 8 155 | 7 156 | 6 157 | # [snip] 158 | # fetch ARF of result 8 of task 1 159 | oscapd-cli result 1 8 arf > exported-arf.xml 160 | ``` 161 | 162 | ### Evaluate something else than local machine 163 | Every task has a *target* attribute that can take various forms: 164 | * localhost -- scan the local machine, the same machine the daemon runs on 165 | * ssh://auditor@192.168.0.22 -- scan remote machine of given IP with given username 166 | * make sure you can log onto the same machine non-interactively! 167 | * ssh+sudo://auditor@192.168.0.22 -- scan remote machine of given IP with given username with sudo privileges 168 | * sudo mustn't require tty 169 | * vm://qemu+kvm://localhost/VM1 -- virtual machine -- work in progress, subject to change 170 | * docker://container_id -- local container -- work in progress, subject to change 171 | 172 | The rest of the use-case is similar to previously mentioned use-cases. It is 173 | important to remark that the *SCAP* content only needs to be available on the 174 | local machine -- the machine that runs *OpenSCAP-daemon*. It is not necessary 175 | to perform any extra manual action to get the content to the scanned machines, 176 | this is done automatically. 177 | 178 | ### Scan all images in my registry to make sure no vulnerable images are published 179 | When maintaining a registry it makes sense to unpublish images that have known 180 | vulnerabilities to prevent people from using them. 181 | 182 | We need to react to the CVE feeds changing and re-scan the images and of course 183 | we need to scan all new images incoming into the registry. 184 | 185 | This is a future use-case that hasn't been fully implemented yet. 186 | 187 | ## Requirements 188 | * [*python2*](http://python.org) >= 2.6 OR [*python3*](http://python.org) >= 3.2 189 | * full source compatibility with *python2* and *python3* 190 | * [*OpenSCAP*](http://open-scap.org) >= 1.2.6 191 | * [*dbus-python*](http://www.freedesktop.org/wiki/Software/DBusBindings/) 192 | * [*flask*](http://flask.pocoo.org/) >= 1.0.2 193 | * (optional) [*Atomic*](http://www.projectatomic.io) >= 1.4 194 | * (optional) [*docker*](http://www.docker.com) 195 | 196 | ## Running the test-suite 197 | The test-suite can be run without installing the software. 198 | 199 | ```bash 200 | cd openscap-daemon 201 | cd tests 202 | ./make_check 203 | ``` 204 | 205 | ## Installation on Linux (standalone on host) 206 | ```bash 207 | cd openscap-daemon 208 | # as a python2 application 209 | sudo python2 setup.py install 210 | # as a python3 application 211 | sudo python3 setup.py install 212 | ``` 213 | 214 | ## Building a container with OpenSCAP Daemon 215 | 216 | Containerized version of OpenSCAP Daemon is used as a backend for the 217 | 'atomic scan' command. Atomic scan can scan containers and images 218 | for vulnerabilities and configuration compliance. 219 | 220 | You can build and install the container image using these commands: 221 | 222 | ```bash 223 | ./generate-dockerfile.py 224 | docker build -t openscap . 225 | atomic install openscap 226 | ``` 227 | 228 | At this point you can run 'atomic scan' on the host. 229 | The image is not meant to be run outside of the atomic command. 230 | 231 | The image is based on Fedora and contains OpenSCAP, OpenSCAP Daemon 232 | and SCAP Security Guide as they are available in Fedora packages. 233 | To install your local working tree of OpenSCAP Daemon instead, add 234 | `--daemon-from-local` to the `./generate-dockerfile.py`. 235 | If you need the latest code from upstream git of OpenSCAP and/or 236 | SCAP Security Guide instead, pass `--openscap-from-git` and/or 237 | `--ssg-from-git` to the `./generate-dockerfile.py`. 238 | 239 | ## REST API 240 | 241 | 242 | ### REST API Endpoints 243 | 244 | | Endpoint | VERB | CLI Equivalent | Description | 245 | |-------------------------------------------|--------|----------------------------------------------|-----------------------------------------------------------| 246 | | /tasks/ | GET | oscapd-cli task | Gets all tasks with their information | 247 | | /tasks/ | POST | oscapd-cli task-create -i | Creates new tasks | 248 | | /tasks// | PUT | oscapd-cli task set-XXX | Modify tasks | 249 | | /tasks// | GET | oscapd-cli task | Gets information about | 250 | | /tasks// | DELETE | oscapd-cli task remove | Remove task | 251 | | /tasks//results/ | DELETE | oscapd-cli task remove | Remove task and its results | 252 | | /tasks//guide/ | GET | oscapd-cli task guide | Gets guide info. Note: HTML Output | 253 | | /tasks/result// | GET | oscapd-cli result report | Gets report for . Note: HTML Output | 254 | | /tasks//result/ | DELETE | oscapd-cli result remove | Removes all results for | 255 | | /tasks//result// | DELETE | oscapd-cli result remove | Removes in | 256 | | /tasks//run/ | GET | oscapd-cli task run | Launch task with id | 257 | | /tasks/// | PUT | oscapd-cli task enable/disable | Enables/Disables | 258 | | /ssgs/ | GET | | Returns all SSGs installed on the system and its profiles | 259 | | /ssgs/ | POST | | Shows SSG's profiles within a json request | 260 | 261 | ### REST API Examples 262 | 263 | Get all tasks: 264 | ``` 265 | curl -i http://127.0.0.1:5000/tasks/ -X GET 266 | ``` 267 | 268 | Create a new task: 269 | ``` 270 | newtask.json 271 | { 272 | "taskTitle":"New Task test", 273 | "taskTarget":"localhost", 274 | "taskSSG":"/usr/share/xml/scap/ssg/content/ssg-jre-ds.xml", 275 | "taskTailoring":"", 276 | "taskProfileId":"xccdf_org.ssgproject.content_profile_stig-java-upstream", 277 | "taskOnlineRemediation":"1", 278 | "taskScheduleNotBefore":"", 279 | "taskScheduleRepeatAfter":"" 280 | } 281 | 282 | curl -i http://127.0.0.1:5000/tasks/ -X POST -H "Content-Type: application/json" -d '@newtask.json' 283 | ``` 284 | 285 | Update an existing task: 286 | ``` 287 | updatetask.json 288 | { 289 | "taskTitle":"New Task test modified", 290 | "taskTarget":"localhost", 291 | "taskSSG":"", 292 | "taskTailoring":"", 293 | "taskProfileId":"", 294 | "taskOnlineRemediation":"yes", 295 | "taskScheduleNotBefore":"", 296 | "taskScheduleRepeatAfter":"" 297 | } 298 | 299 | curl -i http://127.0.0.1:5000/tasks/1/ -X PUT -H "Content-Type: application/json" -d '@updatetask.json' 300 | ``` 301 | 302 | Get info from existing task: 303 | ``` 304 | curl -i http://127.0.0.1:5000/tasks/1/ -X GET 305 | ``` 306 | 307 | Get guide info from existing task: 308 | ``` 309 | curl -i http://127.0.0.1:5000/tasks/1/guide/ -X GET 310 | ``` 311 | 312 | Get result from existing task: 313 | ``` 314 | curl -i http://127.0.0.1:5000/tasks/1/result/1/ -X GET 315 | ``` 316 | 317 | Delete all results from existing task: 318 | ``` 319 | curl -i http://127.0.0.1:5000/tasks/1/result/ -X DELETE 320 | ``` 321 | 322 | Delete result from existing task: 323 | ``` 324 | curl -i http://127.0.0.1:5000/tasks/1/result/1/ -X DELETE 325 | ``` 326 | 327 | Launch existing task: 328 | ``` 329 | curl -i http://127.0.0.1:5000/tasks/1/run/ -X GET 330 | ``` 331 | 332 | Enable/Disable existing task: 333 | ``` 334 | curl -i http://127.0.0.1:5000/tasks/1/enable/ -X PUT 335 | curl -i http://127.0.0.1:5000/tasks/1/disable/ -X PUT 336 | ``` 337 | 338 | Get all SSGs installed on the system: 339 | ``` 340 | curl -i http://127.0.0.1:5000/ssgs/ -X GET 341 | ``` 342 | 343 | Get SSG's profiles using data from a JSON request: 344 | ``` 345 | ssg.json 346 | { 347 | "ssgFile": "/usr/share/xml/scap/ssg/content/ssg-centos7-ds.xml", 348 | "tailoringFile": "" 349 | } 350 | 351 | curl -i http://127.0.0.1:5000/ssgs/ -X POST -H "Content-Type: application/json" -d '@ssg.json' 352 | ``` 353 | 354 | ## API Consumers 355 | > Please do not rely on the API just yet, we reserve the right to make breaking 356 | > changes. The API will stabilize in time for 1.0.0 release. 357 | 358 | OpenSCAP-daemon provides a stable dbus API that is designed to be used by 359 | other projects. 360 | 361 | ### Atomic Integration 362 | OpenSCAP-daemon is used to implement the `atomic scan` functionality. 363 | `atomic scan` allows users to scan containers and container images for 364 | vulnerabilities. 365 | 366 | ### Cockpit Integration 367 | Features: 368 | * declare new tasks, schedule when they run, set how they repeat 369 | * generate HTML guides of scheduled tasks 370 | * show past results of tasks 371 | * get ARFs, HTML reports for past results 372 | * set tasks to automatically push results to external result stores 373 | 374 | ### Foreman Integration 375 | Provide a way to reliably do one-off tasks. Unify various `oscap` runners into 376 | one code-base. 377 | -------------------------------------------------------------------------------- /bin/oscapd: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Copyright 2015 Red Hat Inc., Durham, North Carolina. 4 | # All Rights Reserved. 5 | # 6 | # openscap-daemon is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Lesser General Public License as published by 8 | # the Free Software Foundation, either version 2.1 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # openscap-daemon is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Lesser General Public License for more details. 15 | 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with openscap-daemon. If not, see . 18 | # 19 | # Authors: 20 | # Martin Preisler 21 | 22 | from openscap_daemon import dbus_daemon 23 | from openscap_daemon import dbus_utils 24 | from openscap_daemon import version 25 | from openscap_daemon import system 26 | 27 | import os 28 | import logging 29 | import sys 30 | import argparse 31 | 32 | gobject_mainloop = None 33 | if sys.version_info < (3,): 34 | import gobject 35 | gobject_MainLoop = gobject.MainLoop 36 | else: 37 | from gi.repository import GObject as gobject 38 | from gi.repository import GLib 39 | gobject_MainLoop = GLib.MainLoop 40 | 41 | 42 | def main(): 43 | parser = argparse.ArgumentParser( 44 | description="OpenSCAP-Daemon executable." 45 | ) 46 | parser.add_argument( 47 | "-v", "--version", action="version", 48 | version="%(prog)s " + version.VERSION_STRING 49 | ) 50 | parser.add_argument("--verbose", 51 | help="be verbose, useful for debugging", 52 | action="store_true") 53 | args = parser.parse_args() 54 | 55 | logging.basicConfig(format='%(levelname)s:%(message)s', 56 | level=logging.DEBUG if args.verbose else logging.INFO) 57 | logging.info("OpenSCAP Daemon %s", version.VERSION_STRING) 58 | 59 | import dbus.mainloop.glib 60 | gobject.threads_init() 61 | dbus.mainloop.glib.threads_init() 62 | dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) 63 | 64 | try: 65 | bus = dbus_utils.get_dbus() 66 | name = dbus.service.BusName(dbus_utils.BUS_NAME, bus) 67 | 68 | except dbus.exceptions.DBusException as e: 69 | if e.get_dbus_name() == "org.freedesktop.DBus.Error.AccessDenied": 70 | sys.stderr.write( 71 | "Error: DBus denied access to own '%s'. " 72 | "Do you have the necessary permissions?\n\n" 73 | % (dbus_utils.BUS_NAME) 74 | ) 75 | raise 76 | 77 | config_file = os.path.join("/", "etc", "oscapd", "config.ini") 78 | if "OSCAPD_CONFIG_FILE" in os.environ: 79 | config_file = os.environ["OSCAPD_CONFIG_FILE"] 80 | 81 | system_instance = system.System(config_file) 82 | obj = dbus_daemon.OpenSCAPDaemonDbus(bus, system_instance) 83 | 84 | if system_instance.config.rest_enabled: 85 | try: 86 | lib = __import__("flask") 87 | from openscap_daemon import rest_api 88 | obj2 = rest_api.OpenSCAPRestApi(system_instance) 89 | except ImportError: 90 | sys.stderr.write( 91 | "Error: Rest API enabled in config file, but required libraries not found\n" 92 | "Make sure python-flask is installed on your system.\n\n" 93 | ) 94 | raise 95 | 96 | loop = gobject_MainLoop() 97 | loop.run() 98 | if __name__ == "__main__": 99 | main() 100 | -------------------------------------------------------------------------------- /container/config.ini: -------------------------------------------------------------------------------- 1 | [General] 2 | tasks-dir = /var/lib/oscapd/tasks 3 | results-dir = /var/lib/oscapd/results 4 | work-in-progress-dir = /var/lib/oscapd/work_in_progress 5 | cve-feeds-dir = /var/lib/oscapd/cve_feeds 6 | jobs = 4 7 | 8 | [Tools] 9 | oscap = /usr/bin/oscap 10 | oscap-ssh = /usr/bin/oscap-ssh 11 | oscap-vm = /usr/bin/oscap-vm 12 | oscap-docker = /usr/bin/oscap-docker 13 | oscap-chroot = /usr/bin/oscap-chroot 14 | container-support = yes 15 | 16 | [Content] 17 | cpe-oval = /usr/share/openscap/cpe/openscap-cpe-oval.xml 18 | ssg = /usr/share/xml/scap/ssg/content 19 | 20 | [CVEScanner] 21 | fetch-cve = no 22 | fetch-cve-url = https://www.redhat.com/security/data/oval/ 23 | fetch-cve-timeout = 600 24 | 25 | [REST] 26 | enabled = yes 27 | port = 5000 28 | host = 127.0.0.1 29 | debug = no 30 | 31 | -------------------------------------------------------------------------------- /container/help.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DOCKERFILE="/root/Dockerfile" 4 | 5 | VERSION=$(grep ' version=' $DOCKERFILE | sed 's|.*version="\(.*\)".*|\1|') 6 | RELEASE=$(grep ' release=' $DOCKERFILE | sed 's|.*release="\(.*\)".*|\1|') 7 | if [ -z ${RELEASE} ]; then 8 | echo -e "Image version: ${VERSION}\n" 9 | else 10 | echo -e "Image version: ${VERSION}-${RELEASE}\n" 11 | fi 12 | 13 | DESCRIPTION=$(grep ' description=' $DOCKERFILE \ 14 | | sed 's|.*description="\(.*\)".*|\1|') 15 | echo -e "Description:\n${DESCRIPTION}\n" 16 | 17 | echo "OpenSCAP packages bundled in the image:" 18 | rpm -qa | grep openscap || true 19 | rpm -qa | grep scap-security-guide || true 20 | -------------------------------------------------------------------------------- /container/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ETC='/etc/oscapd' 4 | ETC_FILE='config.ini' 5 | HOST='/host' 6 | SELF=$1 7 | 8 | echo "" 9 | echo "Installing the configuration file 'openscap' into /etc/atomic.d/. You can now use this scanner with atomic scan with the --scanner openscap command-line option. You can also set 'openscap' as the default scanner in /etc/atomic.conf. To list the scanners you have configured for your system, use 'atomic scan --list'." 10 | 11 | echo "" 12 | cp /root/openscap /host/etc/atomic.d/ 13 | sed -i "s|\$IMAGE_NAME|${SELF}|" /host/etc/atomic.d/openscap 14 | 15 | SCRIPTS="/etc/atomic.d/scripts/" 16 | echo "" 17 | echo "Copying the remediation script 'remediate.py' into $SCRIPTS. You can now remediate images with atomic scan using --remediate command-line option." 18 | echo "" 19 | if [[ ! -d $HOST/$SCRIPTS ]]; then 20 | mkdir -p $HOST/$SCRIPTS 21 | fi 22 | cp /root/remediate.py $HOST/$SCRIPTS 23 | 24 | 25 | # Check if /etc/oscapd exists on the host 26 | if [[ ! -d ${HOST}/${ETC} ]]; then 27 | mkdir ${HOST}/${ETC} 28 | fi 29 | 30 | DATE=$(date +'%Y-%m-%d-%T') 31 | 32 | # Check if /etc/oscapd/config.ini exists 33 | if [[ -f ${HOST}/${ETC}/${ETC_FILE} ]]; then 34 | SAVE_NAME=${ETC_FILE}.${DATE}.atomic_save 35 | echo "Saving current ${ETC_FILE} as ${SAVE_NAME}" 36 | mv ${HOST}/${ETC}/${ETC_FILE} ${HOST}/${ETC}/${SAVE_NAME} 37 | fi 38 | 39 | # Add config.ini to the host filesystem 40 | echo "Updating ${ETC_FILE} with latest configuration" 41 | cp /root/config.ini ${HOST}/${ETC}/ 42 | 43 | # Exit Message 44 | echo "Installation complete. You can customize ${ETC}/${ETC_FILE} as needed." 45 | 46 | 47 | echo "" 48 | -------------------------------------------------------------------------------- /container/openscap: -------------------------------------------------------------------------------- 1 | type: scanner 2 | scanner_name: openscap 3 | image_name: $IMAGE_NAME 4 | default_scan: cve 5 | custom_args: ['-v', '/etc/oscapd:/etc/oscapd:ro'] 6 | remediation_script: '/etc/atomic.d/scripts/remediate.py' 7 | scans: [ 8 | { name: cve, 9 | args: ['oscapd-evaluate', 'scan', '--no-standard-compliance', '--targets', 'chroots-in-dir:///scanin', '--output', '/scanout', '-j1'], 10 | description: "Performs a CVE scan based on Red Hat relesead CVE OVAL. !WARNING! This CVE is built into container image and it might be out-of-date. Change config.ini to configure the scanner to fetch latest CVE data"}, 11 | { name: standards_compliance, 12 | args: ['oscapd-evaluate', 'scan', '--targets', 'chroots-in-dir:///scanin', '--output', '/scanout', '--no-cve-scan', '-j1'], 13 | description: "!DEPRECATED! Performs scan with Standard Profile, as present in SCAP Security Guide shipped in Red Hat Enterprise Linux" 14 | }, 15 | { name: configuration_compliance, 16 | args: ['oscapd-evaluate', 'scan', '--targets', 'chroots-in-dir:///scanin', '--output', '/scanout', '--no-cve-scan', '--fix_type', 'bash', '-j1'], 17 | description: "Performs a configuration compliance scan according to selected profile from SCAP Security Guide shipped in Red Hat Enterprise Linux." 18 | } 19 | ] 20 | -------------------------------------------------------------------------------- /container/remediate.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Copyright 2017 Red Hat Inc., Durham, North Carolina. 4 | # All Rights Reserved. 5 | # 6 | # openscap-daemon is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Lesser General Public License as published by 8 | # the Free Software Foundation, either version 2.1 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # openscap-daemon is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Lesser General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with openscap-daemon. If not, see . 18 | # 19 | # Authors: 20 | # Jan Cerny 21 | # Matus Marhefka 22 | 23 | import argparse 24 | import docker 25 | import os 26 | import shutil 27 | import sys 28 | import tempfile 29 | import json 30 | import requests 31 | import re 32 | import xml.etree.ElementTree as ET 33 | 34 | 35 | def remediate(target_id, results_dir): 36 | # Class docker.Client was renamed to docker.APIClient in 37 | # python-docker-py 2.0.0. 38 | try: 39 | client = docker.APIClient() 40 | except AttributeError: 41 | client = docker.Client() 42 | 43 | try: 44 | client.ping() 45 | except requests.exceptions.ConnectionError as e: 46 | raise RuntimeError( 47 | "The Docker daemon does not appear to be running: {}.\n" 48 | .format(e) 49 | ) 50 | 51 | print("Remediating target {}.".format(target_id)) 52 | 53 | temp_dir = tempfile.mkdtemp() 54 | fix_script = os.path.join(results_dir, target_id, "fix.sh") 55 | 56 | try: 57 | shutil.copy(fix_script, temp_dir) 58 | except IOError as e: 59 | raise RuntimeError( 60 | "Can't find a remediation for given image: {}.\n" 61 | .format(e) 62 | ) 63 | 64 | # Finds a platform CPE in the ARF results file and based on it selects 65 | # proper package manager and its cleanup command. Applying cleanup command 66 | # after fix script will produce smaller images after remediation. In case 67 | # a platform CPE is not found in the ARF results file cleanup command is 68 | # left empty. 69 | pkg_clean_cmd = "" 70 | arf_results = os.path.join(results_dir, target_id, "arf.xml") 71 | try: 72 | tree = ET.parse(arf_results) 73 | root = tree.getroot() 74 | except FileNotFoundError as e: 75 | raise RuntimeError(e) 76 | try: 77 | ns = "http://checklists.nist.gov/xccdf/1.2" 78 | platform_cpe = root.find( 79 | ".//{%s}TestResult/{%s}platform" % (ns, ns) 80 | ).attrib['idref'] 81 | except AttributeError: 82 | pass 83 | if "fedora" in platform_cpe: 84 | pkg_clean_cmd = "; dnf clean all" 85 | elif "redhat" in platform_cpe: 86 | try: 87 | distro_version = int(re.search(r"\d+", platform_cpe).group(0)) 88 | except AttributeError: 89 | # In case it is not possible to extract rhel version, use yum. 90 | distro_version = 7 91 | if distro_version >= 8: 92 | pkg_clean_cmd = "; dnf clean all" 93 | else: 94 | pkg_clean_cmd = "; yum clean all" 95 | elif "debian" in platform_cpe: 96 | pkg_clean_cmd = "; apt-get clean; rm -rf /var/lib/apt/lists/*" 97 | elif "ubuntu" in platform_cpe: 98 | pkg_clean_cmd = "; apt-get clean; rm -rf /var/lib/apt/lists/*" 99 | 100 | try: 101 | dockerfile_path = os.path.join(temp_dir, "Dockerfile") 102 | with open(dockerfile_path, "w") as f: 103 | f.write("FROM " + target_id + "\n") 104 | f.write("COPY fix.sh /\n") 105 | # Let's ignore any errors from package cleanup 106 | # It may fail if the system has no connectivity 107 | # or doesn't have a subscription. 108 | f.write( 109 | "RUN chmod +x /fix.sh; /fix.sh {}; true\n" 110 | .format(pkg_clean_cmd) 111 | ) 112 | 113 | try: 114 | build_output_generator = client.build( 115 | path=temp_dir, 116 | # don't use image cache to ensure that original image 117 | # is always remediated 118 | nocache=True, 119 | # remove intermediate containers spawned during build 120 | rm=True 121 | ) 122 | except docker.errors.APIError as e: 123 | raise RuntimeError("Docker exception: {}\n".format(e)) 124 | 125 | build_output = [] 126 | for item in build_output_generator: 127 | item_dict = json.loads(item.decode("utf-8")) 128 | if "error" in item_dict: 129 | raise RuntimeError( 130 | "Error during Docker build {}\n".format(item_dict["error"]) 131 | ) 132 | try: 133 | sys.stdout.write(item_dict["stream"]) 134 | build_output.append(item_dict["stream"]) 135 | except KeyError: 136 | # Skip empty items of build_output_generator. 137 | pass 138 | image_id = build_output[-1].split()[-1] 139 | 140 | print( 141 | "Successfully built remediated image {} from {}.\n" 142 | .format(image_id, target_id) 143 | ) 144 | except RuntimeError as e: 145 | raise RuntimeError( 146 | "Cannot build remediated image from {}: {}\n" 147 | .format(target_id, e) 148 | ) 149 | finally: 150 | shutil.rmtree(temp_dir) 151 | 152 | 153 | if __name__ == "__main__": 154 | parser = argparse.ArgumentParser(description='Remediates container images.') 155 | parser.add_argument("--id", required=True, 156 | help="Image ID") 157 | parser.add_argument("--results_dir", required=True, 158 | help="Directory containing the fix.") 159 | args = parser.parse_args() 160 | try: 161 | remediate(args.id, args.results_dir) 162 | except RuntimeError as e: 163 | sys.stderr.write(str(e)) 164 | sys.exit(1) 165 | -------------------------------------------------------------------------------- /container/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "" 4 | if [ ! -e /host/etc/atomic.d/openscap ]; then 5 | echo "No 'openscap' file found in /etc/atomic.d. This image requires you install it with 'atomic install rhel7/openscap'" 6 | 7 | else 8 | echo "This container/image is not meant to be run outside of the atomic command. You can use this image by issuing 'atomic scan to scan'. See 'atomic scan --help' for more information." 9 | 10 | fi 11 | 12 | echo "" 13 | 14 | -------------------------------------------------------------------------------- /generate-dockerfile.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import argparse 4 | import collections 5 | import contextlib 6 | 7 | 8 | INDENTATION = " " 9 | COMMAND_DELIMITER = " \\\n{}&& ".format(INDENTATION) 10 | 11 | labels = [ 12 | ("com.redhat.component", "openscap-container"), 13 | ("name", "openscap"), 14 | ("version", "testing"), 15 | ("usage", "The OpenSCAP container image is used by the Atomic Management Tool. See 'atomic scan --help' for usage summary."), 16 | ("architecture", "x86_64"), 17 | ("summary", "OpenSCAP container image that provides security/compliance scanning capabilities for 'atomic scan'"), 18 | ("description", "This image can be used by 'atomic scan' to perform two types of scans: Scanning for vulnerabilities using RHEL CVE feeds, which are already part of the image, informs you about installed applications that have known security issues. Scanning for configuration compliance can confirm that the scanned system complies to a given security profile. In cases that it does not comply, you can try to fix the failing rules by passing '--remediate' as an atomic scan argument."), 19 | ("io.k8s.display-name", "OpenSCAP"), 20 | ("io.k8s.description", "This image can be used by 'atomic scan' to perform two types of scans: Scanning for vulnerabilities using RHEL CVE feeds, which are already part of the image, informs you about installed applications that have known security issues. Scanning for configuration compliance can confirm that the scanned system complies to a given security profile. In cases that it does not comply, you can try to fix the failing rules by passing '--remediate' as an atomic scan argument."), 21 | ("io.openshift.tags", "security openscap scan"), 22 | ("install", "docker run --rm --privileged -v /:/host/ IMAGE sh /root/install.sh IMAGE"), 23 | ("run", "docker run -it --rm -v /:/host/ IMAGE sh /root/run.sh"), 24 | ("help", "docker run -it --rm IMAGE sh /root/help.sh"), 25 | ] 26 | 27 | packages = { 28 | "openssh-clients", 29 | "wget", 30 | "bzip2", 31 | } 32 | 33 | files = [ 34 | ("container/install.sh", "/root/"), 35 | ("container/run.sh", "/root/"), 36 | ("container/openscap", "/root/"), 37 | ("container/config.ini", "/root/"), 38 | ("container/remediate.py", "/root/"), 39 | ("container/help.sh", "/root/"), 40 | ("Dockerfile", "/root/"), 41 | ] 42 | env_variables = [ 43 | ("container", "oci") 44 | ] 45 | download_cve_feeds_command = [ 46 | "wget --no-verbose -P /var/lib/oscapd/cve_feeds/ " 47 | "https://www.redhat.com/security/data/oval/com.redhat.rhsa-RHEL{5,6,7,8}.xml.bz2", 48 | "bzip2 -dk /var/lib/oscapd/cve_feeds/com.redhat.rhsa-RHEL{5,6,7,8}.xml.bz2", 49 | "ln -s /var/lib/oscapd/cve_feeds/ /var/tmp/image-scanner", 50 | ] 51 | openscap_build_command = [ 52 | "git clone -b maint-1.2 https://github.com/OpenSCAP/openscap.git", 53 | "pushd /openscap", 54 | "./autogen.sh", 55 | "./configure --enable-sce --prefix=/usr", 56 | "make -j 4 install", 57 | "popd", 58 | ] 59 | ssg_build_command = [ 60 | "git clone https://github.com/OpenSCAP/scap-security-guide.git", 61 | "pushd /scap-security-guide/build", 62 | "cmake -DCMAKE_INSTALL_DATADIR=/usr/share ..", 63 | "make -j 4 install", 64 | "popd", 65 | ] 66 | daemon_local_build_command = [ 67 | "pushd /openscap-daemon", 68 | "python setup.py install", 69 | "popd", 70 | ] 71 | 72 | 73 | def make_parser(): 74 | parser = argparse.ArgumentParser(description="Builds an image with OpenSCAP Daemon") 75 | 76 | openscap_group = parser.add_mutually_exclusive_group(required=False) 77 | parser.add_argument( 78 | "--base", type=str, default="fedora", 79 | help="Base image name (default is fedora)") 80 | openscap_group.add_argument( 81 | "--openscap-from-git", action="store_true", 82 | default=False, help="Use OpenSCAP from upstream instead of package") 83 | openscap_group.add_argument( 84 | "--openscap-from-koji", type=str, 85 | help="Use OpenSCAP from Koji based on build ID (Fedora only)") 86 | 87 | ssg_group = parser.add_mutually_exclusive_group(required=False) 88 | ssg_group.add_argument( 89 | "--ssg-from-koji", type=str, 90 | help="Use SCAP Security Guide from Koji based on build ID (Fedora only)") 91 | ssg_group.add_argument( 92 | "--ssg-from-git", action="store_true", default=False, 93 | help="Use SCAP Security Guide from upstream instead of package") 94 | 95 | daemon_group = parser.add_mutually_exclusive_group(required=False) 96 | daemon_group.add_argument( 97 | "--daemon-from-local", action="store_true", default=False, 98 | help="Use OpenSCAP Daemon from local working tree instead of package") 99 | daemon_group.add_argument( 100 | "--daemon-from-koji", type=str, 101 | help="Use OpenSCAP Daemon from Koji based on build ID (Fedora only)") 102 | return parser 103 | 104 | 105 | def output_baseimage_line(baseimage_name): 106 | return "FROM {0}\n\n".format(baseimage_name) 107 | 108 | 109 | def output_labels_lines(label_value_pairs): 110 | label_value_lines = [ 111 | '{}="{}"'.format(label, value) 112 | for label, value in label_value_pairs] 113 | label_value_lines = ['LABEL'] + label_value_lines 114 | label_statement = " \\\n{}".format(INDENTATION).join(label_value_lines) 115 | return label_statement 116 | 117 | 118 | def output_env_lines(env_value_pairs): 119 | envvar_value_lines = [ 120 | '{}="{}"'.format(envvar, value) 121 | for envvar, value in env_value_pairs] 122 | envvar_value_lines = ['ENV'] + envvar_value_lines 123 | env_statement = " \\\n{}".format(INDENTATION).join(envvar_value_lines) 124 | return env_statement 125 | 126 | 127 | def _aggregate_by_destination(src_dest_pairs): 128 | destinations = collections.defaultdict(set) 129 | for src, dest in src_dest_pairs: 130 | destinations[dest].add(src) 131 | return destinations 132 | 133 | 134 | def _output_copy_lines_for_destination(sources, destination): 135 | elements = ['COPY'] + list(sources) + [destination] 136 | if len(sources) == 1: 137 | copy_statement = " ".join(elements) 138 | else: 139 | copy_statement = " \\\n{}".format(INDENTATION).join(elements) 140 | return copy_statement 141 | 142 | 143 | def output_copy_lines(src_dest_pairs): 144 | destinations = _aggregate_by_destination(src_dest_pairs) 145 | copy_statements = [] 146 | for dest, sources in destinations.items(): 147 | statement = _output_copy_lines_for_destination(sources, dest) 148 | copy_statements.append(statement) 149 | return "\n".join(copy_statements) 150 | 151 | 152 | class PackageEnv(object): 153 | def __init__(self): 154 | self.install_command_beginning = None 155 | self.remove_command_beginning = None 156 | self.clear_cache = None 157 | self.builddep_package = None 158 | self.builddep_command_beginning = None 159 | self.additional_repositories_were_enabled = False 160 | 161 | def _assert_class_is_complete(self): 162 | assert ( 163 | self.install_command_beginning is not None 164 | and self.remove_command_beginning is not None 165 | and self.clear_cache is not None 166 | and self.builddep_package is not None 167 | and self.builddep_command_beginning is not None 168 | ), "The class {} is not complete, use a fully defined child." 169 | 170 | def install_command_element(self, packages_string): 171 | return "{} {}".format(self.install_command_beginning, packages_string) 172 | 173 | def remove_command_element(self, packages_string): 174 | return "{} {}".format(self.remove_command_beginning, packages_string) 175 | 176 | def _enable_additional_repositories_command_element(self): 177 | return [] 178 | 179 | def get_enable_additional_repositories_command_element(self): 180 | if not self.additional_repositories_were_enabled: 181 | return self._enable_additional_repositories_command_element() 182 | else: 183 | return [] 184 | self.additional_repositories_were_enabled = True 185 | 186 | def _get_install_commands(self, packages_string): 187 | self._assert_class_is_complete() 188 | commands = self.get_enable_additional_repositories_command_element() 189 | commands.append(self.install_command_element(packages_string)) 190 | return commands 191 | 192 | @contextlib.contextmanager 193 | def install_then_clean_all(self, packages_string): 194 | commands = self._get_install_commands(packages_string) 195 | yield commands 196 | commands.append(self.clear_cache) 197 | 198 | @contextlib.contextmanager 199 | def install_then_remove(self, packages_string, clear_cache_afterwards=False): 200 | commands = self._get_install_commands(packages_string) 201 | yield commands 202 | commands.append(self.remove_command_element(packages_string)) 203 | if clear_cache_afterwards: 204 | commands.append(self.clear_cache) 205 | 206 | 207 | class RhelEnv(PackageEnv): 208 | def __init__(self): 209 | super(RhelEnv, self).__init__() 210 | self.install_command_beginning = "yum install -y" 211 | self.remove_command_beginning = "yum remove -y" 212 | self.clear_cache = "yum clean all" 213 | self.builddep_command_beginning = "yum-builddep -y" 214 | self.builddep_package = "yum-utils" 215 | 216 | def _enable_additional_repositories_command_element(self): 217 | commands = super(RhelEnv, self)._enable_additional_repositories_command_element() 218 | commands.append( 219 | "rpm -Uvh https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm") 220 | return commands 221 | 222 | 223 | class FedoraEnv(PackageEnv): 224 | def __init__(self): 225 | super(FedoraEnv, self).__init__() 226 | self.install_command_beginning = "dnf install -y" 227 | self.remove_command_beginning = "dnf remove -y" 228 | self.clear_cache = "dnf clean all" 229 | self.builddep_command_beginning = "dnf -y builddep" 230 | self.builddep_package = "'dnf-command(builddep)'" 231 | 232 | 233 | def choose_pkg_env_class(baseimage): 234 | if baseimage.startswith("fedora"): 235 | return FedoraEnv 236 | else: 237 | return RhelEnv 238 | 239 | 240 | class TasksRecorder(object): 241 | def __init__(self, builddep_package): 242 | self.builddep_package = builddep_package 243 | 244 | self._build_from_source = [] 245 | self._build_commands = [] 246 | 247 | self._install_from_koji = [] 248 | self._koji_commands = [] 249 | 250 | def merge(self, rhs): 251 | self._build_from_source.extend(rhs._build_from_source) 252 | self._build_commands.extend(rhs._build_commands) 253 | self._koji_commands.extend(rhs._koji_commands) 254 | self._install_from_koji.extend(rhs._install_from_koji) 255 | 256 | def build_from_source(self, what, how=None): 257 | packages.add(self.builddep_package) 258 | 259 | self._build_from_source.append(what) 260 | if how is not None: 261 | self._build_commands.extend(how) 262 | 263 | def install_from_koji(self, what, how=None): 264 | self._install_from_koji.append(what) 265 | if how is not None: 266 | self._koji_commands.extend(how) 267 | 268 | def install_build_deps(self, builddep_command): 269 | if len(self._build_from_source) == 0: 270 | return [] 271 | build_deps_string = " ".join(self._build_from_source) 272 | command = "{0} {1}".format(builddep_command, build_deps_string) 273 | return [command] 274 | 275 | def add_commands_for_building_from_custom_sources(self): 276 | return self._build_commands 277 | 278 | def add_koji_commands(self): 279 | return self._koji_commands 280 | 281 | 282 | def decide_about_getting_openscap(args, pkg_env): 283 | tasks = TasksRecorder(pkg_env.builddep_package) 284 | if args.openscap_from_git: 285 | packages.update({"git", "libtool", "automake"}) 286 | tasks.build_from_source("openscap", openscap_build_command) 287 | elif args.openscap_from_koji is not None: 288 | packages.add("koji") 289 | openscap_koji_command = [ 290 | "koji download-build -a x86_64 {0}".format(args.openscap_from_koji), 291 | "koji download-build -a noarch {0}".format(args.openscap_from_koji), 292 | pkg_env.install_command_element( 293 | "openscap-[0-9]*.rpm openscap-scanner*.rpm " 294 | "openscap-utils*.rpm openscap-containers*.rpm"), 295 | "rm -f openscap-*.rpm", 296 | ] 297 | tasks.install_from_koji("openscap", openscap_koji_command) 298 | else: 299 | packages.add("openscap-utils") 300 | 301 | return tasks 302 | 303 | 304 | def decide_about_getting_ssg(args, pkg_env): 305 | tasks = TasksRecorder(pkg_env.builddep_package) 306 | if args.ssg_from_git: 307 | packages.add("git") 308 | tasks.build_from_source("scap-security-guide", ssg_build_command) 309 | elif args.ssg_from_koji is not None: 310 | packages.add("koji") 311 | ssg_koji_command = [ 312 | "koji download-build -a noarch {0}".format(args.ssg_from_koji), 313 | pkg_env.install_command_element("scap-security-guide-[0-9]*.rpm"), 314 | "rm -f scap-security-guide*.rpm", 315 | ] 316 | tasks.install_from_koji("scap-security-guide", ssg_koji_command) 317 | else: 318 | packages.add("scap-security-guide") 319 | 320 | return tasks 321 | 322 | 323 | def decide_about_getting_openscap_daemon(args, pkg_env): 324 | tasks = TasksRecorder(pkg_env.builddep_package) 325 | if args.daemon_from_local: 326 | tasks.build_from_source("openscap-daemon", daemon_local_build_command) 327 | files.append((".", "/openscap-daemon/")) 328 | elif args.daemon_from_koji is not None: 329 | packages.add("koji") 330 | daemon_koji_command = [ 331 | "koji download-build -a noarch {0}".format(args.daemon_from_koji), 332 | pkg_env.install_command_element("openscap-daemon*.rpm"), 333 | "rm -f openscap-daemon*.rpm", 334 | ] 335 | tasks.install_from_koji("openscap-daemon", daemon_koji_command) 336 | else: 337 | packages.add("openscap-daemon") 338 | 339 | return tasks 340 | 341 | 342 | def output_run_directive(commands): 343 | commands_string = COMMAND_DELIMITER.join(["true"] + commands + ["true"]) 344 | return "RUN {}\n\n".format(commands_string) 345 | 346 | 347 | def main(): 348 | parser = make_parser() 349 | args = parser.parse_args() 350 | pkg_env = choose_pkg_env_class(args.base)() 351 | 352 | if (not isinstance(pkg_env, FedoraEnv)) and ( 353 | args.openscap_from_koji is not None 354 | or args.ssg_from_koji is not None 355 | or args.daemon_from_koji is not None): 356 | parser.error("Koji builds can be used only with fedora base image") 357 | 358 | with open("Dockerfile", "w") as f: 359 | # write out the Dockerfile 360 | f.write(output_baseimage_line(args.base)) 361 | 362 | f.write(output_labels_lines(labels)) 363 | f.write("\n\n") 364 | 365 | f.write(output_env_lines(env_variables)) 366 | f.write("\n\n") 367 | 368 | install_steps = decide_about_getting_openscap(args, pkg_env) 369 | install_steps.merge(decide_about_getting_ssg(args, pkg_env)) 370 | install_steps.merge(decide_about_getting_openscap_daemon(args, pkg_env)) 371 | 372 | # inject files 373 | f.write(output_copy_lines(files)) 374 | f.write("\n\n") 375 | 376 | run_commands = [] 377 | 378 | packages_string = " ".join(packages) 379 | with pkg_env.install_then_clean_all(packages_string) as commands: 380 | commands.extend( 381 | install_steps.install_build_deps(pkg_env.builddep_command_beginning)) 382 | 383 | commands.extend( 384 | install_steps.add_commands_for_building_from_custom_sources()) 385 | 386 | commands.extend( 387 | install_steps.add_koji_commands()) 388 | 389 | run_commands.extend(commands) 390 | f.write(output_run_directive(run_commands)) 391 | 392 | f.write(output_run_directive(download_cve_feeds_command)) 393 | 394 | # add CMD instruction to the Dockerfile, including a comment 395 | f.write("# It doesn't matter what is in the line below, atomic will change the CMD\n") 396 | f.write("# before running it\n") 397 | f.write('CMD ["/root/run.sh"]\n') 398 | 399 | 400 | if __name__ == "__main__": 401 | main() 402 | -------------------------------------------------------------------------------- /man/oscapd-cli.8: -------------------------------------------------------------------------------- 1 | .TH OSCAPD-CLI "8" "January 2016" "Red Hat" "System Administration Utilities" 2 | 3 | .SH NAME 4 | oscapd-cli \- OpenSCAP-daemon command line interface 5 | 6 | .SH SYNOPSIS 7 | \fBoscapd-cli\fR [\fI-h\fR] {eval,scan,task,task-create,status,result} 8 | 9 | .SS "positional arguments:" 10 | .IP 11 | {eval,task,task\-create,status,result} 12 | .TP 13 | eval 14 | Interactive one\-off evaluation of any target supported 15 | by OpenSCAP Daemon 16 | .TP 17 | task 18 | Show info about tasks that have already been defined. 19 | Perform operations on already defined tasks. 20 | .TP 21 | task\-create 22 | Create new task. 23 | .TP 24 | status 25 | Displays status, tasks that are planned and tasks that 26 | are being evaluated. 27 | .TP 28 | result 29 | Displays info about past results 30 | 31 | .SS "optional arguments:" 32 | .TP 33 | \fB\-h\fR, \fB\-\-help\fR 34 | show this help message and exit 35 | .TP 36 | \fB\-v\fR, \fB\-\-version\fR 37 | show program's version number and exit 38 | 39 | .SH AUTHORS 40 | .nf 41 | Martin Preisler 42 | Brent Baude 43 | Zbynek Moravec 44 | .fi 45 | -------------------------------------------------------------------------------- /man/oscapd-evaluate.8: -------------------------------------------------------------------------------- 1 | .TH OSCAPD-EVALUATE "8" "March 2016" "Red Hat" "System Administration Utilities" 2 | 3 | .SH NAME 4 | oscapd-evaluate \- OpenSCAP-daemon one-off non-daemonized evaluator 5 | 6 | .SH SYNOPSIS 7 | \fBoscapd-evaluate\fR [\fI-h\fR] [\fI-v\fR] [\fI--verbose\fR] {config,xml,spec,target-cpes,target-profiles,scan} 8 | 9 | .SS "positional arguments:" 10 | .IP 11 | {config,xml,spec,target\-cpes,target\-profiles,scan} 12 | .TP 13 | config 14 | Generate default config file for oscapd and oscapd-evaluate. 15 | .TP 16 | xml 17 | Evaluates Evaluation Spec from given XML input. 18 | .TP 19 | spec 20 | Constructs Evaluation Spec from given parameters and evaluates it or outputs its XML. 21 | .TP 22 | target\-cpes 23 | Detects CPEs of given target. 24 | .TP 25 | target\-profiles 26 | Detects configuration compliance profiles from SCAP Security Guide applicable on given target. 27 | .TP 28 | scan 29 | Performs CVE scan and/or configuration compliance evaluation of given targets. Outputs raw XML results and a summary JSON output. 30 | 31 | .SS "optional arguments:" 32 | .TP 33 | \fB\-h\fR, \fB\-\-help\fR 34 | show this help message and exit 35 | .TP 36 | \fB\-v\fR, \fB\-\-version\fR 37 | show program's version number and exit 38 | .TP 39 | \fB\-\-verbose\fR 40 | Show debugging logging messages. 41 | 42 | .SH AUTHORS 43 | .nf 44 | Martin Preisler 45 | .fi 46 | -------------------------------------------------------------------------------- /man/oscapd.8: -------------------------------------------------------------------------------- 1 | .TH OSCAPD "8" "Jan 2016" "Red Hat" "System Administration Utilities" 2 | 3 | .SH NAME 4 | oscapd \- OpenSCAP-daemon service 5 | 6 | .SH SYNOPSIS 7 | \fBoscapd\fR [\fI-h\fR] [\fI-v\fR] [\fI--verbose\fR] 8 | 9 | .SH DESCRIPTION 10 | \fBoscapd\fP is the central executable of OpenSCAP-daemon. When started it provides a dbus interface that other tools (such as oscapd-cli and atomic) can interact with. 11 | 12 | In production environments it is not recommended to start the service directly, use \fBsystemctl start oscapd\fP instead. 13 | 14 | .SH GENERAL OPTIONS 15 | .TP 16 | \fB\-v, \-\-version\fR 17 | Show version. 18 | .TP 19 | \fB\-h, \-\-help\fR 20 | Help screen. 21 | .TP 22 | \fB\-\-verbose\fR 23 | Start in verbose mode. 24 | 25 | .SH AUTHORS 26 | .nf 27 | Martin Preisler 28 | Brent Baude 29 | Zbynek Moravec 30 | .fi 31 | -------------------------------------------------------------------------------- /openscap_daemon/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Red Hat Inc., Durham, North Carolina. 2 | # All Rights Reserved. 3 | # 4 | # openscap-daemon is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Lesser General Public License as published by 6 | # the Free Software Foundation, either version 2.1 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # openscap-daemon is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Lesser General Public License for more details. 13 | 14 | # You should have received a copy of the GNU Lesser General Public License 15 | # along with openscap-daemon. If not, see . 16 | # 17 | # Authors: 18 | # Martin Preisler 19 | 20 | 21 | from openscap_daemon.evaluation_spec import EvaluationSpec 22 | from openscap_daemon.system import System 23 | from openscap_daemon.task import Task 24 | 25 | 26 | __all__ = ["EvaluationSpec", "System", "Task"] 27 | -------------------------------------------------------------------------------- /openscap_daemon/async_tools.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Red Hat Inc., Durham, North Carolina. 2 | # All Rights Reserved. 3 | # 4 | # openscap-daemon is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Lesser General Public License as published by 6 | # the Free Software Foundation, either version 2.1 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # openscap-daemon is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Lesser General Public License for more details. 13 | 14 | # You should have received a copy of the GNU Lesser General Public License 15 | # along with openscap-daemon. If not, see . 16 | # 17 | # Authors: 18 | # Martin Preisler 19 | 20 | import threading 21 | import logging 22 | import time 23 | import sys 24 | import traceback 25 | if sys.version_info < (3,): 26 | import Queue as queue 27 | else: 28 | import queue 29 | 30 | 31 | class Status(object): 32 | """This enum describes status of async actions. Calls can be pending, 33 | processing or done. When actions are done they are waiting for the caller 34 | to collect the results and then they are deleted entirely. 35 | """ 36 | 37 | PENDING = 0 38 | PROCESSING = 1 39 | #DONE = 2 40 | UNKNOWN = 3 41 | 42 | @staticmethod 43 | def from_string(status): 44 | if status == "pending": 45 | return Status.PENDING 46 | elif status == "processing": 47 | return Status.PROCESSING 48 | #elif status == "done": 49 | # return Status.DONE 50 | 51 | return Status.UNKNOWN 52 | 53 | @staticmethod 54 | def to_string(status): 55 | if status == Status.PENDING: 56 | return "pending" 57 | elif status == Status.PROCESSING: 58 | return "processing" 59 | #elif status == Status.DONE: 60 | # return "done" 61 | 62 | return "unknown" 63 | 64 | 65 | class AsyncAction(object): 66 | def __init__(self): 67 | self.token = -1 68 | self.status = Status.UNKNOWN 69 | 70 | def run(self): 71 | pass 72 | 73 | def __str__(self): 74 | return "Unknown action" 75 | 76 | 77 | class AsyncManager(object): 78 | """Allows the user to enqueue asynchronous actions, gives the user a token 79 | they can poll as often as they like and check status of the actions. 80 | 81 | This is necessary to run many tasks in parallel and is necessary to make 82 | the dbus API work smoothly. User calling dbus methods doesn't expect them 83 | to take hours to finish. The calls themselves need to finish in seconds. 84 | To make it work with OpenSCAP evaluations that regularly take tens of 85 | minutes we create a task by the dbus call and then poll it. 86 | """ 87 | 88 | def _worker_main(self, worker_id): 89 | while True: 90 | priority, action = self.queue.get(True) 91 | 92 | logging.debug( 93 | "Worker %i starting action from the priority queue. " 94 | "priority=%i, token=%i, action='%s'", 95 | worker_id, priority, action.token, action 96 | ) 97 | 98 | action.status = Status.PROCESSING 99 | try: 100 | action.run() 101 | 102 | except BaseException as e: 103 | logging.error("Action '%s' threw an exception that hasn't been " 104 | "caught. This is most likely a bug, please" 105 | "report it. %s" % (action, e)) 106 | exc_type, exc_value, tb = sys.exc_info() 107 | traceback.print_tb(tb, file=sys.stderr) 108 | 109 | self.queue.task_done() 110 | 111 | with self.actions_lock: 112 | del self.actions[action.token] 113 | 114 | time.sleep(self.sleep_time) 115 | 116 | def __init__(self, workers=0): 117 | self.queue = queue.PriorityQueue() 118 | 119 | self.sleep_time = 1 120 | 121 | if workers == 0: 122 | try: 123 | import multiprocessing 124 | workers = multiprocessing.cpu_count() 125 | 126 | except NotImplementedError: 127 | workers = 4 128 | 129 | self.workers = [] 130 | 131 | for i in range(workers): 132 | worker = threading.Thread( 133 | name="AsyncManager worker (%i out of %i)" % (i, workers), 134 | target=AsyncManager._worker_main, 135 | args=(self, i) 136 | ) 137 | worker.daemon = True 138 | self.workers.append(worker) 139 | worker.start() 140 | 141 | self.last_token = 0 142 | self.actions = {} 143 | self.actions_lock = threading.Lock() 144 | 145 | logging.debug("Initialized AsyncManager, %i workers", 146 | len(self.workers)) 147 | 148 | def _allocate_token(self): 149 | with self.actions_lock: 150 | ret = self.last_token + 1 151 | self.last_token = ret 152 | assert(ret not in self.actions) 153 | 154 | return ret 155 | 156 | def enqueue(self, action, priority=0): 157 | action.token = self._allocate_token() 158 | action.status = Status.PENDING 159 | 160 | with self.actions_lock: 161 | self.actions[action.token] = action 162 | self.queue.put((priority, action)) 163 | 164 | logging.debug("AsyncManager enqueued action '%s' with token %i", 165 | action, action.token) 166 | return action.token 167 | 168 | def get_status(self): 169 | ret = [] 170 | for token, action in self.actions.items(): 171 | ret.append((token, str(action), action.status)) 172 | 173 | return ret 174 | 175 | def cancel(self, token): 176 | raise NotImplementedError() 177 | -------------------------------------------------------------------------------- /openscap_daemon/cli_helpers.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Red Hat Inc., Durham, North Carolina. 2 | # All Rights Reserved. 3 | # 4 | # openscap-daemon is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Lesser General Public License as published by 6 | # the Free Software Foundation, either version 2.1 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # openscap-daemon is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Lesser General Public License for more details. 13 | 14 | # You should have received a copy of the GNU Lesser General Public License 15 | # along with openscap-daemon. If not, see . 16 | # 17 | # Authors: 18 | # Martin Preisler 19 | 20 | import sys 21 | import os.path 22 | import logging 23 | from openscap_daemon import evaluation_spec 24 | try: 25 | import xml.etree.cElementTree as ElementTree 26 | except ImportError: 27 | import xml.etree.ElementTree as ElementTree 28 | 29 | if sys.version_info < (3,): 30 | py2_raw_input = raw_input 31 | else: 32 | py2_raw_input = input 33 | 34 | 35 | def print_table(table, first_row_header=True): 36 | """Takes given table - list of lists - and prints it as a table, using 37 | ASCII characters for formatting. 38 | 39 | The first row is formatted as a header. 40 | 41 | I did consider using some python package or module to do this but that 42 | would introduce additional dependencies. The functionality we need is simple 43 | enough to write it ourselves. 44 | """ 45 | 46 | column_max_sizes = {} 47 | for row in table: 48 | for i, column_cell in enumerate(row): 49 | if i not in column_max_sizes: 50 | column_max_sizes[i] = 0 51 | 52 | column_max_sizes[i] = \ 53 | max(column_max_sizes[i], len(str(column_cell))) 54 | 55 | total_width = len(" | ".join( 56 | [" " * max_size for max_size in column_max_sizes.values()] 57 | )) 58 | 59 | start_row = 0 60 | 61 | if first_row_header: 62 | assert(len(table) > 0) 63 | 64 | print("-+-".join( 65 | "-" * max_size for max_size in column_max_sizes.values()) 66 | ) 67 | print(" | ".join( 68 | [str(cell).ljust(column_max_sizes[table[start_row].index(cell)]) 69 | for cell in table[start_row]] 70 | )) 71 | print("-+-".join( 72 | "-" * max_size for max_size in column_max_sizes.values()) 73 | ) 74 | start_row += 1 75 | 76 | for row in table[start_row:]: 77 | print(" | ".join( 78 | [str(cell).ljust(column_max_sizes[row.index(cell)]) 79 | for cell in row] 80 | )) 81 | 82 | 83 | def cli_create_evaluation_spec(dbus_iface): 84 | """Interactively create EvaluationSpec and return it. Returns None if user 85 | cancels the action. 86 | """ 87 | print("Creating EvaluationSpec interactively...") 88 | print("") 89 | 90 | try: 91 | target = py2_raw_input("Target (empty for localhost): ") 92 | if not target: 93 | target = "localhost" 94 | 95 | print("Found the following SCAP Security Guide content: ") 96 | ssg_choices = dbus_iface.GetSSGChoices() 97 | for i, ssg_choice in enumerate(ssg_choices): 98 | print("\t%i: %s" % (i + 1, ssg_choice)) 99 | 100 | input_file = None 101 | input_ssg_choice = py2_raw_input( 102 | "Choose SSG content by number (empty for custom content): ") 103 | if not input_ssg_choice: 104 | input_file = py2_raw_input("Input file (absolute path): ") 105 | else: 106 | input_file = ssg_choices[int(input_ssg_choice) - 1] 107 | 108 | input_file = os.path.abspath(input_file) 109 | 110 | tailoring_file = py2_raw_input( 111 | "Tailoring file (absolute path, empty for no tailoring): ") 112 | if tailoring_file in [None, ""]: 113 | tailoring_file = "" 114 | else: 115 | tailoring_file = os.path.abspath(tailoring_file) 116 | 117 | print("Found the following possible profiles: ") 118 | profile_choices = dbus_iface.GetProfileChoicesForInput( 119 | input_file, tailoring_file 120 | ) 121 | for i, (key, value) in enumerate(profile_choices.items()): 122 | print("\t%i: %s (id='%s')" % (i + 1, value, key)) 123 | 124 | profile_choice = py2_raw_input( 125 | "Choose profile by number (empty for (default) profile): ") 126 | if profile_choice is not None: 127 | profile = list(profile_choices.keys())[int(profile_choice) - 1] 128 | else: 129 | profile = None 130 | 131 | online_remediation = False 132 | if py2_raw_input("Online remediation (1, y or Y for yes, else no): ") \ 133 | in ["1", "y", "Y"]: 134 | online_remediation = True 135 | 136 | ret = evaluation_spec.EvaluationSpec() 137 | ret.target = target 138 | ret.input_.set_file_path(input_file) 139 | if tailoring_file not in [None, ""]: 140 | ret.tailoring.set_file_path(tailoring_file) 141 | ret.profile_id = profile 142 | ret.online_remediation = online_remediation 143 | 144 | return ret 145 | 146 | except KeyboardInterrupt: 147 | return None 148 | 149 | 150 | def preprocess_targets(targets, output_dir_map): 151 | """The main goal of this function is to expand chroots-in-dir:// to a list 152 | of chroot:// targets. chroots-in-dir is a convenience function that the rest 153 | of the OpenSCAP-daemon API doesn't know about. 154 | 155 | The output_dir_map maps the processed targets to directories from 156 | chroots-in-dir expansion. 157 | """ 158 | 159 | ret = [] 160 | 161 | for target in targets: 162 | if target.startswith("chroots-in-dir://"): 163 | logging.debug("Expanding target '%s'...", target) 164 | 165 | dir_ = os.path.abspath(target[len("chroots-in-dir://"):]) 166 | for chroot in os.listdir(dir_): 167 | full_path = os.path.abspath(os.path.join(dir_, chroot)) 168 | 169 | if not os.path.isdir(full_path): 170 | continue 171 | 172 | expanded_target = "chroot://" + full_path 173 | logging.debug(" ... '%s'", expanded_target) 174 | ret.append(expanded_target) 175 | output_dir_map[expanded_target] = chroot 176 | 177 | logging.debug("Finished expanding target '%s'.", target) 178 | 179 | else: 180 | ret.append(target) 181 | 182 | return ret 183 | 184 | 185 | def summarize_cve_results(oval_source, result_list): 186 | """Takes given OVAL source, assuming it is CVE feed OVAL results source, 187 | and parses it. Each definition that has result 'true' is added to 188 | result_list. 189 | 190 | This is used to produce JSON output for atomic scan in 191 | `oscapd-evaluate scan`. 192 | """ 193 | 194 | namespaces = { 195 | "ovalres": "http://oval.mitre.org/XMLSchema/oval-results-5", 196 | "ovaldef": "http://oval.mitre.org/XMLSchema/oval-definitions-5" 197 | } 198 | 199 | oval_root = ElementTree.fromstring(oval_source.encode("utf-8")) 200 | 201 | for result in oval_root.findall( 202 | "ovalres:results/ovalres:system/" 203 | "ovalres:definitions/*[@result='true']", 204 | namespaces): 205 | definition_id = result.get("definition_id") 206 | assert(definition_id is not None) 207 | 208 | definition_meta = oval_root.find( 209 | "./ovaldef:oval_definitions/ovaldef:definitions/*[@id='%s']/" 210 | "ovaldef:metadata" % (definition_id), 211 | namespaces 212 | ) 213 | assert(definition_meta is not None) 214 | 215 | title = definition_meta.find("ovaldef:title", namespaces) 216 | # there can only be one RHSA per definition 217 | rhsa = definition_meta.find("ovaldef:reference[@source='RHSA']", 218 | namespaces) 219 | # there can be one or more CVEs per definition 220 | cves = definition_meta.findall("ovaldef:reference[@source='CVE']", 221 | namespaces) 222 | description = definition_meta.find("ovaldef:description", namespaces) 223 | severity = definition_meta.find("ovaldef:advisory/ovaldef:severity", 224 | namespaces) 225 | 226 | result_json = {} 227 | result_json["Title"] = title.text if title is not None else "unknown" 228 | result_json["Description"] = \ 229 | description.text if description is not None else "unknown" 230 | result_json["Severity"] = \ 231 | severity.text if severity is not None else "unknown" 232 | 233 | custom = {} 234 | if rhsa is not None: 235 | custom["RHSA ID"] = rhsa.get("ref_id", "unknown") 236 | custom["RHSA URL"] = rhsa.get("ref_url", "unknown") 237 | 238 | if len(cves) > 0: 239 | custom["Associated CVEs"] = [] 240 | 241 | for cve in cves: 242 | custom["Associated CVEs"].append( 243 | {"CVE ID": cve.get("ref_id", "unknown"), 244 | "CVE URL": cve.get("ref_url", "unknown")} 245 | ) 246 | 247 | result_json["Custom"] = custom 248 | 249 | result_list.append(result_json) 250 | 251 | 252 | def summarize_standard_compliance_results(arf_source, result_list, profile): 253 | """Takes given ARF XML source and parses it. Each Rule that doesn't have 254 | result 'pass', 'fixed', 'informational', 'notselected' or 'notapplicable' 255 | is added to result_list. 256 | 257 | This is used to produce JSON output for atomic scan in 258 | `oscapd-evaluate scan`. 259 | """ 260 | 261 | namespaces = { 262 | "cdf": "http://checklists.nist.gov/xccdf/1.2", 263 | } 264 | 265 | arf_root = ElementTree.fromstring(arf_source.encode("utf-8")) 266 | 267 | test_result = arf_root.find( 268 | ".//cdf:TestResult[@id='%s']" % 269 | # this ID prefix is hardcoded in oscap 270 | ("xccdf_org.open-scap_testresult_" + profile), namespaces 271 | ) 272 | 273 | benchmark = arf_root.find(".//cdf:Benchmark", namespaces) 274 | 275 | for rule_result in test_result.findall("./cdf:rule-result", namespaces): 276 | result = rule_result.find("cdf:result", namespaces).text 277 | 278 | if result in ["pass", "fixed", "informational", "notselected", 279 | "notapplicable"]: 280 | continue 281 | 282 | rule_id = rule_result.get("idref") 283 | assert(rule_id is not None) 284 | 285 | rule = benchmark.find(".//cdf:Rule[@id='%s']" % (rule_id), namespaces) 286 | assert(rule is not None) 287 | 288 | title = rule.find("cdf:title", namespaces) 289 | description = rule.find("cdf:description", namespaces) 290 | severity = rule.get("severity", "Unknown") 291 | if severity in "low": 292 | severity = "Low" 293 | elif severity == "medium": 294 | severity = "Moderate" 295 | elif severity == "high": 296 | severity = "Important" 297 | else: # "info", a valid XCCDF severity falls here 298 | severity = "Unknown" 299 | 300 | result_json = {} 301 | result_json["Title"] = title.text if title is not None else "unknown" 302 | if description is None: 303 | result_json["Description"] = "unknown" 304 | else: 305 | result_json["Description"] = ElementTree.tostring(description, method="text").decode("utf-8") 306 | result_json["Severity"] = severity 307 | result_json["Custom"] = {"XCCDF result": result} 308 | 309 | result_list.append(result_json) 310 | -------------------------------------------------------------------------------- /openscap_daemon/compat.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Red Hat Inc., Durham, North Carolina. 2 | # All Rights Reserved. 3 | # 4 | # openscap-daemon is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Lesser General Public License as published by 6 | # the Free Software Foundation, either version 2.1 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # openscap-daemon is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Lesser General Public License for more details. 13 | 14 | # You should have received a copy of the GNU Lesser General Public License 15 | # along with openscap-daemon. If not, see . 16 | # 17 | # Authors: 18 | # Martin Preisler 19 | 20 | import subprocess 21 | 22 | 23 | def subprocess_check_output(*popenargs, **kwargs): 24 | # Backport of subprocess.check_output taken from 25 | # https://gist.github.com/edufelipe/1027906 26 | # 27 | # Originally from Python 2.7 stdlib under PSF, compatible with LGPL2+ 28 | # Copyright (c) 2003-2005 by Peter Astrand 29 | # Changes by Eduardo Felipe 30 | 31 | process = subprocess.Popen(stdout=subprocess.PIPE, *popenargs, **kwargs) 32 | output, unused_err = process.communicate() 33 | retcode = process.poll() 34 | if retcode: 35 | cmd = kwargs.get("args") 36 | if cmd is None: 37 | cmd = popenargs[0] 38 | error = subprocess.CalledProcessError(retcode, cmd) 39 | error.output = output 40 | raise error 41 | return output 42 | 43 | 44 | if hasattr(subprocess, "check_output"): 45 | # if available we just use the real function 46 | subprocess_check_output = subprocess.check_output 47 | 48 | __all__ = ["subprocess_check_output"] 49 | -------------------------------------------------------------------------------- /openscap_daemon/cve_feed_manager.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2016 Red Hat Inc., Durham, North Carolina. 2 | # Copyright (C) 2015 Brent Baude 3 | # 4 | # This library is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU Lesser General Public 6 | # License as published by the Free Software Foundation; either 7 | # version 2 of the License, or (at your option) any later version. 8 | # 9 | # This library is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 12 | # Lesser General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Lesser General Public 15 | # License along with this library; if not, write to the 16 | # Free Software Foundation, Inc., 59 Temple Place - Suite 330, 17 | # Boston, MA 02111-1307, USA. 18 | 19 | try: 20 | # Python2 imports 21 | import urlparse 22 | import urllib2 as urllib 23 | 24 | except ImportError: 25 | # Python3 imports 26 | import urllib.parse as urlparse 27 | import urllib.request as urllib 28 | 29 | import os 30 | import os.path 31 | import time 32 | import datetime 33 | import logging 34 | import bz2 35 | import threading 36 | 37 | 38 | class CVEFeedManager(object): 39 | """Class to obtain the CVE data provided by RH and possibly other vendors. 40 | The CVE data is used to scan for CVEs using OpenSCAP 41 | """ 42 | 43 | default_url = "https://www.redhat.com/security/data/oval/" 44 | 45 | class HeadRequest(urllib.Request): 46 | def get_method(self): 47 | return "HEAD" 48 | 49 | def __init__(self, dest="/tmp"): 50 | self.dest = dest 51 | self.hdr = {"User-agent": "Mozilla/5.0"} 52 | self.hdr2 = [("User-agent", "Mozilla/5.0")] 53 | self.url = CVEFeedManager.default_url 54 | self.remote_dist_cve_name = "com.redhat.rhsa-RHEL{0}.xml.bz2" 55 | self.local_dist_cve_name = "com.redhat.rhsa-RHEL{0}.xml" 56 | self.dists = [5, 6, 7, 8] 57 | self.remote_pattern = '%a, %d %b %Y %H:%M:%S %Z' 58 | 59 | self.fetch_enabled = True 60 | # check for fresh CVE feeds at most every 10 minutes 61 | self.fetch_timeout = 10 * 60 62 | # A map of remote URIs to the time we last checked them for fresh 63 | # content. 64 | self.fetch_last_checked = {} 65 | # Let us only check for fresh CVEs once at a time 66 | self.fetch_lock = threading.Lock() 67 | 68 | def _parse_http_headers(self, http_headers): 69 | """Returns dictionary containing HTTP headers with lowercase keys 70 | """ 71 | 72 | headers_dict = dict(http_headers) 73 | return dict((key.lower(), value) for key, value in headers_dict.items()) 74 | 75 | def _print_no_last_modified_warning(self, url): 76 | logging.warning( 77 | "Warning: Response header of HTTP doesn't contain " 78 | "\"last-modified\" field. Cannot determine version" 79 | " of remote file \"{0}\"".format(url) 80 | ) 81 | 82 | def _is_cache_same(self, local_file, remote_url): 83 | """Checks if the local cache version and the upstream 84 | version is the same or not. If they are the same, 85 | returns True; else False. 86 | """ 87 | 88 | with self.fetch_lock: 89 | if not os.path.exists(local_file): 90 | logging.debug( 91 | "No local file cached, will fetch {0}".format(remote_url) 92 | ) 93 | return False 94 | 95 | last_checked = self.fetch_last_checked.get(remote_url, 0) 96 | now = time.time() 97 | 98 | if now - last_checked <= self.fetch_timeout: 99 | logging.debug( 100 | "Checked for fresh version of '%s' just %f seconds ago. " 101 | "Will wait %f seconds before checking again.", 102 | remote_url, now - last_checked, 103 | self.fetch_timeout - now + last_checked 104 | ) 105 | return True 106 | 107 | opener = urllib.build_opener() 108 | # Add the header 109 | opener.addheaders = self.hdr2 110 | # Grab the header 111 | try: 112 | res = opener.open(CVEFeedManager.HeadRequest(remote_url)) 113 | headers = self._parse_http_headers(res.info()) 114 | res.close() 115 | remote_ts = headers['last-modified'] 116 | 117 | except urllib.HTTPError as http_error: 118 | logging.debug( 119 | "Cannot send HTTP HEAD request to get \"last-modified\" " 120 | "attribute of remote content file.\n{0} - {1}" 121 | .format(http_error.code, http_error.reason) 122 | ) 123 | return False 124 | 125 | except KeyError: 126 | self._print_no_last_modified_warning(remote_url) 127 | return False 128 | 129 | self.fetch_last_checked[remote_url] = time.time() 130 | 131 | # The remote's datetime 132 | remote_dt = datetime.datetime.strptime( 133 | remote_ts, self.remote_pattern 134 | ) 135 | # Get the locals datetime from the file's mtime, converted to UTC 136 | local_dt = datetime.datetime.utcfromtimestamp( 137 | os.stat(local_file).st_mtime 138 | ) 139 | 140 | # Giving a two second comfort zone 141 | # Else we declare they are different 142 | if (remote_dt - local_dt).seconds > 2: 143 | logging.info("Had a local version of {0} " 144 | "but it wasn't new enough".format(local_file)) 145 | return False 146 | 147 | logging.debug("File {0} is same as upstream".format(local_file)) 148 | return True 149 | 150 | def get_rhel_cve_feed(self, dist): 151 | """Given a distribution number (i.e. 7), it will fetch the 152 | distribution specific data file if upstream has a newer 153 | input file. Returns the path of file. 154 | 155 | If we already have a cached version that is fresh it will just 156 | return the path. 157 | """ 158 | 159 | local_file = os.path.join( 160 | self.dest, self.local_dist_cve_name.format(dist) 161 | ) 162 | if not self.fetch_enabled: 163 | return local_file 164 | 165 | remote_url = urlparse.urljoin( 166 | self.url, self.remote_dist_cve_name.format(dist) 167 | ) 168 | if self._is_cache_same(local_file, remote_url): 169 | return local_file 170 | 171 | _url = urllib.Request(remote_url, headers=self.hdr) 172 | 173 | try: 174 | resp = urllib.urlopen(_url) 175 | 176 | except Exception as url_error: 177 | raise Exception("Unable to fetch CVE inputs due to {0}" 178 | .format(url_error)) 179 | 180 | fh = open(local_file, "wb") 181 | fh.write(bz2.decompress(resp.read())) 182 | fh.close() 183 | 184 | # Correct Last-Modified timestamp 185 | headers = self._parse_http_headers(resp.info()) 186 | resp.close() 187 | try: 188 | remote_ts = headers['last-modified'] 189 | epoch = datetime.datetime.utcfromtimestamp(0) 190 | remote_dt = datetime.datetime.strptime(remote_ts, self.remote_pattern) 191 | seconds_epoch = (remote_dt - epoch).total_seconds() 192 | os.utime(local_file, (seconds_epoch, seconds_epoch)) 193 | 194 | except KeyError: 195 | self._print_no_last_modified_warning(remote_url) 196 | 197 | return local_file 198 | 199 | def fetch_all_rhel_cve_feeds(self): 200 | """Fetches all the the distribution specific data used for 201 | input with openscap cve scanning and returns a list 202 | of those files. 203 | """ 204 | 205 | cve_files = [] 206 | for dist in self.dists: 207 | cve_files.append(self.get_cve_feed(dist)) 208 | return cve_files 209 | 210 | def get_cve_feed(self, cpe_ids): 211 | if "cpe:/o:redhat:enterprise_linux:8" in cpe_ids: 212 | return self.get_rhel_cve_feed(8) 213 | if "cpe:/o:redhat:enterprise_linux:7" in cpe_ids: 214 | return self.get_rhel_cve_feed(7) 215 | elif "cpe:/o:redhat:enterprise_linux:6" in cpe_ids: 216 | return self.get_rhel_cve_feed(6) 217 | elif "cpe:/o:redhat:enterprise_linux:5" in cpe_ids: 218 | return self.get_rhel_cve_feed(5) 219 | 220 | raise RuntimeError( 221 | "Can't find a supported CPE ID in %s" % (", ".join(cpe_ids)) 222 | ) 223 | 224 | def get_cve_feed_last_updated(self, cpe_ids): 225 | local_file = self.get_cve_feed(cpe_ids) 226 | assert(os.path.exists(local_file)) 227 | # local timestamp, local timezone datetime 228 | return datetime.datetime.fromtimestamp(os.path.getmtime(local_file)) 229 | -------------------------------------------------------------------------------- /openscap_daemon/cve_scanner/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Red Hat Inc., Durham, North Carolina. 2 | # 3 | # All Rights Reserved. 4 | # 5 | # openscap-daemon is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Lesser General Public License as published by 7 | # the Free Software Foundation, either version 2.1 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # openscap-daemon is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Lesser General Public License for more details. 14 | 15 | # You should have received a copy of the GNU Lesser General Public License 16 | # along with openscap-daemon. If not, see . 17 | 18 | 19 | #__all__ = [] 20 | -------------------------------------------------------------------------------- /openscap_daemon/cve_scanner/applicationconfiguration.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Brent Baude 2 | # Copyright (C) 2015 Red Hat Inc., Durham, North Carolina. 3 | # 4 | # This library is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU Lesser General Public 6 | # License as published by the Free Software Foundation; either 7 | # version 2 of the License, or (at your option) any later version. 8 | # 9 | # This library is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 12 | # Lesser General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Lesser General Public License 15 | # along with openscap-daemon. If not, see . 16 | 17 | # TODO: Integrate this to openscap_daemon.config package 18 | 19 | from openscap_daemon.cve_scanner.scanner_error import ImageScannerClientError 20 | 21 | 22 | class ApplicationConfiguration(object): 23 | '''Application Configuration''' 24 | def __init__(self, parserargs=None): 25 | ''' Init for Application Configuration ''' 26 | self.workdir = parserargs.workdir 27 | self.logfile = parserargs.logfile 28 | self.number = parserargs.number 29 | self.reportdir = parserargs.reportdir 30 | self.fetch_cve = parserargs.fetch_cve 31 | self.fcons = None 32 | self.cons = None 33 | self.images = None 34 | self.allimages = None 35 | self.return_json = None 36 | self.conn = self.ValidateHost(parserargs.host) 37 | self.parserargs = parserargs 38 | self.json_url = None 39 | # "" means we will use oscap-docker defaults, else a string with URL 40 | # is expected. example: "https://www.redhat.com/security/data/oval/" 41 | self.fetch_cve_url = parserargs.fetch_cve_url 42 | 43 | def ValidateHost(self, host): 44 | ''' Validates if the defined docker host is running''' 45 | try: 46 | import docker 47 | except ImportError: 48 | error = "Can't import 'docker' package. Has docker been installed?" 49 | raise ImageScannerClientError(error) 50 | 51 | # Class docker.Client was renamed to docker.APIClient in 52 | # python-docker-py 2.0.0. 53 | try: 54 | client = docker.APIClient(base_url=host, timeout=11) 55 | except AttributeError: 56 | client = docker.Client(base_url=host, timeout=11) 57 | 58 | if not client.ping(): 59 | error = "Cannot connect to the Docker daemon. Is it running " \ 60 | "on this host?" 61 | raise ImageScannerClientError(error) 62 | return client 63 | -------------------------------------------------------------------------------- /openscap_daemon/cve_scanner/generate_summary.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Brent Baude 2 | # Copyright (C) 2015 Red Hat Inc., Durham, North Carolina. 3 | # 4 | # This library is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU Lesser General Public 6 | # License as published by the Free Software Foundation; either 7 | # version 2 of the License, or (at your option) any later version. 8 | # 9 | # This library is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 12 | # Lesser General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Lesser General Public License 15 | # along with openscap-daemon. If not, see . 16 | 17 | ''' 18 | Functions used by the docker_scanner to 19 | generate the results dict from the oscap results.xml files 20 | ''' 21 | 22 | 23 | import xml.etree.ElementTree as ET 24 | from collections import namedtuple 25 | from openscap_daemon.cve_scanner.scanner_error import ImageScannerClientError 26 | import json 27 | import sys 28 | if sys.version_info < (3,): 29 | import urlparse 30 | else: 31 | import urllib.parse as urlparse 32 | 33 | 34 | class Create_Summary(object): 35 | ''' Class that provides the functions ''' 36 | 37 | _cve_tuple = namedtuple('oval_cve', ['title', 'severity', 'cve_ref_id', 38 | 'cve_ref_url', 'rhsa_ref_id', 'rhsa_ref_url', 39 | 'cve', 'description']) 40 | 41 | def __init__(self): 42 | self.containers = None 43 | self.images = None 44 | self.cve_info = None 45 | 46 | def _get_root(self, result_file): 47 | ''' 48 | Returns an ET object for the input XML which can be a file 49 | or a URL pointing to an xml file 50 | ''' 51 | from openscap_daemon.cve_scanner.image_scanner_client import Client 52 | 53 | if result_file.startswith("http://"): 54 | split_url = urlparse.urlsplit(result_file) 55 | image_scanner = Client(split_url.hostname, port=split_url.port) 56 | result_tree = image_scanner.getxml(result_file) 57 | else: 58 | result_tree = ET.parse(result_file) 59 | return result_tree.getroot() 60 | 61 | def _get_list_cve_def_ids(self, _root): 62 | '''Returns a list of cve definition ids in the result file''' 63 | _def_id_list = [] 64 | definitions = _root.findall("{http://oval.mitre.org/XMLSchema/" 65 | "oval-results-5}results/{http://oval.mitre" 66 | ".org/XMLSchema/oval-results-5}system/{" 67 | "http://oval.mitre.org/XMLSchema/oval-" 68 | "results-5}definitions/*[@result='true']") 69 | for def_id in definitions: 70 | _def_id_list.append(def_id.attrib['definition_id']) 71 | 72 | return _def_id_list 73 | 74 | def _get_cve_def_info(self, _def_id_list, _root): 75 | ''' 76 | Returns a list of tuples that contain information about the 77 | cve themselves. Currently return are: title, severity, ref_id 78 | and ref_url for the cve and rhsa, the cve id, and description 79 | ''' 80 | 81 | cve_info_list = [] 82 | for def_id in _def_id_list: 83 | oval_defs = _root.find("{http://oval.mitre.org/XMLSchema/oval-" 84 | "definitions-5}oval_definitions/{http://" 85 | "oval.mitre.org/XMLSchema/oval-definitions-" 86 | "5}definitions/*[@id='%s']/{http://oval." 87 | "mitre.org/XMLSchema/oval-definitions-5}" 88 | "metadata" % def_id) 89 | # title 90 | title = oval_defs.find("{http://oval.mitre.org/XMLSchema/oval-" 91 | "definitions-5}title").text 92 | rhsa_meta = oval_defs.find("{http://oval.mitre.org/XMLSchema/oval" 93 | "-definitions-5}reference[@source=" 94 | "'RHSA']") 95 | cve_meta = oval_defs.find("{http://oval.mitre.org/XMLSchema/oval-" 96 | "definitions-5}reference[@source='CVE']") 97 | # description 98 | description = oval_defs.find("{http://oval.mitre.org/XMLSchema/" 99 | "oval-definitions-5}description").text 100 | # severity 101 | severity = oval_defs.find("{http://oval.mitre.org/XMLSchema/oval-" 102 | "definitions-5}advisory/{http://oval." 103 | "mitre.org/XMLSchema/oval-definitions" 104 | "-5}severity").text 105 | cve_info_list.append( 106 | self._cve_tuple(title=title, severity=severity, 107 | cve_ref_id=None if cve_meta is None 108 | else cve_meta.attrib['ref_id'], 109 | cve_ref_url=None if cve_meta is None 110 | else cve_meta.attrib['ref_url'], 111 | rhsa_ref_id=rhsa_meta.attrib['ref_id'], 112 | rhsa_ref_url=rhsa_meta.attrib['ref_url'], 113 | cve=def_id.replace( 114 | "oval:com.redhat.rhsa:def:", ""), 115 | description=description)) 116 | 117 | return cve_info_list 118 | 119 | def get_cve_info(self, result_file): 120 | ''' 121 | Wrapper function to return a list of tuples with 122 | cve information from the xml input file 123 | ''' 124 | _root = self._get_root(result_file) 125 | _id_list = self._get_list_cve_def_ids(_root) 126 | return self._get_cve_def_info(_id_list, _root) 127 | 128 | def _return_cve_dict_info(self, title): 129 | ''' 130 | Returns a dict containing the specific details of a cve which 131 | includes title, rhsa/cve ref_ids and urls, cve number, and 132 | description. 133 | ''' 134 | 135 | cve_tuple = [cved for cved in self.cve_info if cved.title == title][0] 136 | cve_dict_info = {'cve_title': cve_tuple.title, 137 | 'cve_ref_id': cve_tuple.cve_ref_id, 138 | 'cve_ref_url': cve_tuple.cve_ref_url, 139 | 'rhsa_ref_id': cve_tuple.rhsa_ref_id, 140 | 'rhsa_ref_url': cve_tuple.rhsa_ref_url, 141 | 'cve': cve_tuple.cve 142 | } 143 | 144 | return cve_dict_info 145 | 146 | def _summarize_docker_object(self, result_file, docker_json, item_id): 147 | ''' 148 | takes a result.xml file and a docker state json file and 149 | compares output to give an analysis of a given scan 150 | ''' 151 | 152 | self.cve_info = self.get_cve_info(result_file) 153 | 154 | affected_image = 0 155 | affected_children = [] 156 | is_image = self.is_id_an_image(item_id, docker_json) 157 | 158 | summary = {} 159 | if is_image: 160 | summary['scanned_image'] = item_id 161 | affected_image = item_id 162 | affected_children = self._process_image(affected_image, 163 | docker_json) 164 | else: 165 | summary['scanned_container'] = item_id 166 | affected_children, affected_image = \ 167 | self._process_container(docker_json, item_id) 168 | 169 | summary['image'] = affected_image 170 | summary['containers'] = affected_children 171 | 172 | scan_results = {} 173 | for cve in self.cve_info: 174 | _cve_specifics = self._return_cve_dict_info(cve.title) 175 | if cve.severity not in scan_results: 176 | scan_results[cve.severity] = \ 177 | {'num': 1, 178 | 'cves': [_cve_specifics]} 179 | else: 180 | scan_results[cve.severity]['num'] += 1 181 | scan_results[cve.severity]['cves'].append(_cve_specifics) 182 | summary['scan_results'] = scan_results 183 | # self.debug_json(summary) 184 | return summary 185 | 186 | def _process_container(self, docker_json, item_id): 187 | ''' 188 | Returns containers with the same base image 189 | as a list 190 | ''' 191 | affected_children = [] 192 | for image_id in docker_json['docker_state']: 193 | for containers in docker_json['docker_state'][image_id]: 194 | if item_id == containers['uuid']: 195 | base_image = image_id 196 | for containers in docker_json['docker_state'][base_image]: 197 | affected_children.append(containers['uuid']) 198 | 199 | return affected_children, base_image 200 | 201 | # Deprecate or rewrite 202 | def _process_image(self, affected_image, docker_json): 203 | ''' 204 | Returns containers with a given base 205 | as a list 206 | ''' 207 | affected_children = [] 208 | # Catch an image that has no containers 209 | if affected_image not in docker_json['docker_state']: 210 | return [] 211 | # It has children containers 212 | for containers in docker_json['docker_state'][affected_image]: 213 | affected_children.append(containers['uuid']) 214 | return affected_children 215 | 216 | def is_id_an_image(self, docker_id, docker_obj): 217 | ''' 218 | helper function that uses the docker_state_file to validate if the 219 | given item_id is a container or image id 220 | ''' 221 | 222 | if self.containers is None or self.images is None: 223 | self.containers = docker_obj['host_containers'] 224 | self.images = docker_obj['host_images'] 225 | 226 | if docker_id in self.images: 227 | return True 228 | elif docker_id in self.containers: 229 | return False 230 | else: 231 | # Item was not found in the docker state file 232 | error_msg = 'The provided openscap xml result file was ' \ 233 | 'not generated from the same run as the ' \ 234 | 'docker state file ' 235 | raise ImageScannerClientError(error_msg) 236 | 237 | def debug_json(self, json_data): 238 | ''' Pretty prints a json object for debug purposes ''' 239 | print(json.dumps(json_data, indent=4, separators=(',', ': '))) 240 | -------------------------------------------------------------------------------- /openscap_daemon/cve_scanner/image_scanner_client.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Brent Baude 2 | # Copyright (C) 2015 Red Hat Inc., Durham, North Carolina. 3 | # 4 | # This library is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU Lesser General Public 6 | # License as published by the Free Software Foundation; either 7 | # version 2 of the License, or (at your option) any later version. 8 | # 9 | # This library is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 12 | # Lesser General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Lesser General Public License 15 | # along with openscap-daemon. If not, see . 16 | 17 | 18 | ''' Image scanner API ''' 19 | 20 | from openscap_daemon.cve_scanner.scanner_error import ImageScannerClientError 21 | 22 | import json 23 | import xml.etree.ElementTree as ET 24 | import collections 25 | import os 26 | import sys 27 | from multiprocessing.dummy import Pool as ThreadPool 28 | 29 | # TODO: External dep, verify that we really need it! 30 | import requests 31 | 32 | if sys.version_info < (3,): 33 | import urlparse 34 | import ConfigParser 35 | else: 36 | import urllib.parse as urlparse 37 | import configparser as ConfigParser 38 | 39 | 40 | class Client(requests.Session): 41 | ''' The image-scanner client API ''' 42 | 43 | request_headers = {'content-type': 'application/json'} 44 | 45 | def __init__(self, host, port=5001, number=2): 46 | ''' 47 | When instantiating, pass in the host and optionally 48 | the port and threading counts 49 | ''' 50 | super(Client, self).__init__() 51 | self.host = "http://{0}:{1}" .format(host, port) 52 | self.api_path = "image-scanner/api" 53 | self.num_threads = number 54 | self.client_common = ClientCommon() 55 | 56 | def scan_all_containers(self, onlyactive=False): 57 | ''' Scans all containers and returns results in json''' 58 | url = urlparse.urljoin(self.host, self.api_path + "/scan") 59 | con_scan = 'allcontainers' if onlyactive is False else 'onlyactive' 60 | params = {con_scan: True, 'number': self.num_threads} 61 | results = self._get_results(url, data=json.dumps(params)) 62 | self._check_result(results) 63 | return json.loads(results.text) 64 | 65 | def scan_list(self, scan_list): 66 | ''' 67 | Scans a list of containers/images by name or id and returns 68 | results in json 69 | ''' 70 | if not isinstance(scan_list, list): 71 | raise ImageScannerClientError("You must pass input in list form") 72 | url = urlparse.urljoin(self.host, self.api_path + "/scan") 73 | params = {'scan': scan_list, 'number': self.num_threads} 74 | results = self._get_results(url, data=json.dumps(params)) 75 | self._check_result(results) 76 | return json.loads(results.text) 77 | 78 | def scan_images(self, all=False): 79 | '''Scans all images and returns results in json''' 80 | url = urlparse.urljoin(self.host, self.api_path + "/scan") 81 | if all: 82 | params = {'allimages': True, 'number': self.num_threads} 83 | else: 84 | params = {'images': True, 'number': self.num_threads} 85 | results = self._get_results(url, data=json.dumps(params)) 86 | self._check_result(results) 87 | return json.loads(results.text) 88 | 89 | def inspect_container(self, cid): 90 | '''Inspects a container and returns all results in json''' 91 | url = urlparse.urljoin(self.host, self.api_path + "/inspect_container") 92 | results = self._get_results(url, data=json.dumps({'cid': cid})) 93 | return json.loads(results.text) 94 | 95 | def inspect_image(self, iid): 96 | '''Inspects a container and returns the results in json''' 97 | url = urlparse.urljoin(self.host, self.api_path + "/inspect_image") 98 | results = self._get_results(url, json.dumps({'iid': iid})) 99 | return json.loads(results.text) 100 | 101 | def getxml(self, url): 102 | ''' 103 | Given a URL string, returns the results of an openscap XML file as 104 | an Element Tree 105 | ''' 106 | try: 107 | results = self.get(url) 108 | except requests.exceptions.ConnectionError: 109 | raise ImageScannerClientError("Unable to connect to REST server " 110 | "at {0}".format(url)) 111 | return ET.ElementTree(ET.fromstring(results.content)) 112 | 113 | def get_docker_json(self, url): 114 | ''' 115 | Given a URL, return the state of the docker containers and images 116 | when the images-scanning occurred. Returns as JSON object. 117 | ''' 118 | try: 119 | results = self.get(url) 120 | except requests.exceptions.ConnectionError: 121 | raise ImageScannerClientError("Unable to connect to REST server " 122 | "at {0}".format(url)) 123 | return json.loads(results.text) 124 | 125 | def _get_results(self, url, data=None, headers=None): 126 | '''Wrapper functoin for calling the request.session.get''' 127 | headers = self.request_headers if headers is None else headers 128 | try: 129 | if data is not None: 130 | results = self.get(url, data=data, 131 | headers=headers) 132 | else: 133 | results = self.get(url, headers=headers, timeout=9) 134 | except requests.exceptions.ConnectionError: 135 | raise ImageScannerClientError("Unable to connect to REST server " 136 | "at {0}".format(url)) 137 | except requests.exceptions.Timeout: 138 | raise ImageScannerClientError("Timeout reached with REST server " 139 | "at {0}".format(url)) 140 | 141 | return results 142 | 143 | @staticmethod 144 | def _check_result(result): 145 | ''' 146 | Examines a json object looking for a key of 'Error' 147 | which indicates the previous call did not work. Raises 148 | an exception upon finding the key 149 | ''' 150 | result_json = json.loads(result.text) 151 | if 'Error' in result_json: 152 | raise ImageScannerClientError(result_json['Error']) 153 | 154 | if 'results' in result_json.keys() and 'Error' \ 155 | in result_json['results']: 156 | raise ImageScannerClientError(result_json['results']['Error']) 157 | 158 | def ping(self): 159 | ''' 160 | Throws an exception if it cannot access the REST server or 161 | the docker host 162 | ''' 163 | url = urlparse.urljoin(self.host, self.api_path + "/ping") 164 | results = self._get_results(url) 165 | if 'results' not in json.loads(results.text): 166 | tmp_obj = json.loads(results.text) 167 | if hasattr(tmp_obj, 'error'): 168 | error = getattr(tmp_obj, 'error') 169 | else: 170 | error = tmp_obj['Error'] 171 | 172 | error = error.replace('on the host ', 'on the host {0} ' 173 | .format(self.host)) 174 | raise ImageScannerClientError(error) 175 | 176 | 177 | class ClientCommon(object): 178 | ''' Clients functions that are shared with other classes ''' 179 | 180 | config_file = "/etc/image-scanner/image-scanner-client.conf" 181 | profile_tuple = collections.namedtuple('profiles', ['profile', 182 | 'host', 183 | 'port', 184 | 'cert', 185 | 'number']) 186 | args_tuple = collections.namedtuple('scan_args', 187 | ['allimages', 'images', 188 | 'allcontainers', 'onlyactive']) 189 | 190 | client_dir = "/var/tmp/image-scanner/client" 191 | 192 | if not os.path.exists(client_dir): 193 | os.makedirs(client_dir) 194 | 195 | uber_file_path = os.path.join(client_dir, 'uber_docker.json') 196 | 197 | def __init__(self): 198 | self.uber_docker = {} 199 | self.num_complete = 0 200 | self.num_total = 0 201 | self.last_completed = "" 202 | self.threads = 0 203 | 204 | @staticmethod 205 | def debug_json(json_data): 206 | ''' Debug function that pretty prints json objects''' 207 | print(json.dumps(json_data, indent=4, separators=(',', ': '))) 208 | 209 | def get_profile_info(self, profile): 210 | ''' Looks for host and port based on the profile provided ''' 211 | 212 | config = ConfigParser.RawConfigParser() 213 | config.read(self.config_file) 214 | try: 215 | port = config.get(profile, 'port') 216 | host = config.get(profile, 'host') 217 | cert = None if not config.has_option(profile, 'cert') else \ 218 | config.get(profile, 'cert') 219 | number = 2 if not config.has_option(profile, 'threads') else \ 220 | config.get(profile, 'threads') 221 | except ConfigParser.NoSectionError: 222 | raise ImageScannerClientError("The profile {0} cannot be found " 223 | "in {1}".format(profile, 224 | self.config_file)) 225 | except ConfigParser.NoOptionError as no_option: 226 | print("No option {0} found in profile "\ 227 | "{1} in {2}".format(no_option.option, 228 | profile, 229 | self.config_file)) 230 | return host, port, number, cert 231 | 232 | def _make_profile_tuple(self, host, port, number, cert, section): 233 | ''' Creates the profile_tuple and returns it ''' 234 | return self.profile_tuple(profile=section, host=host, port=port, 235 | cert=None, number=number) 236 | 237 | def return_profiles(self, input_profile_list): 238 | ''' 239 | Returns a list of tuples with information about the 240 | input profiles 241 | ''' 242 | profile_list = [] 243 | config = ConfigParser.ConfigParser() 244 | config.read(self.config_file) 245 | for profile in input_profile_list: 246 | host, port, number, cert = self.get_profile_info(profile) 247 | if self.threads > 0: 248 | number = self.threads 249 | profile_list.append(self._make_profile_tuple(host, port, 250 | number, cert, profile)) 251 | return profile_list 252 | 253 | def return_all_profiles(self): 254 | ''' Returns a list of tuples with host and port information ''' 255 | 256 | profile_list = [] 257 | config = ConfigParser.ConfigParser() 258 | config.read(self.config_file) 259 | for section in config.sections(): 260 | host, port, number, cert = self.get_profile_info(section) 261 | profile_list.append(self._make_profile_tuple(host, port, number, 262 | cert, section)) 263 | return profile_list 264 | 265 | def get_all_profile_names(self): 266 | ''' Returns a list of all profile names ''' 267 | 268 | profile_names = [] 269 | all_profiles = self.return_all_profiles() 270 | for profile in all_profiles: 271 | profile_names.append(profile.profile) 272 | return profile_names 273 | 274 | def thread_profile_wrapper(self, args): 275 | ''' Simple wrapper for thread_profiles ''' 276 | return self.thread_profiles(*args) 277 | 278 | def thread_profiles(self, profile, onlyactive, allcontainers, 279 | allimages, images): 280 | ''' Kicks off a scan of for a remote host''' 281 | scanner = Client(profile.host, profile.port, number=profile.number) 282 | try: 283 | if onlyactive: 284 | results = scanner.scan_all_containers(onlyactive=True) 285 | elif allcontainers: 286 | results = scanner.scan_all_containers() 287 | elif allimages: 288 | results = scanner.scan_images(all=True) 289 | else: 290 | results = scanner.scan_images() 291 | except ImageScannerClientError as scan_error: 292 | results = json.dumps({'error': str(scan_error)}) 293 | 294 | host_state = results if 'error' in results else \ 295 | scanner.get_docker_json(results['json_url']) 296 | self.uber_docker[profile.profile] = host_state 297 | self.num_complete += 1 298 | self.last_completed = " Completed {0}".format(profile.profile) 299 | 300 | def scan_multiple_hosts(self, profile_list, allimages=False, images=False, 301 | allcontainers=False, onlyactive=False, 302 | remote_threads=4, threads=0): 303 | ''' 304 | Scan multiple hosts and returns an uber-docker object 305 | which is basically an object with one or more docker 306 | state objects in it. 307 | ''' 308 | 309 | if (threads > 0): 310 | self.threads = threads 311 | 312 | if (threads < 2 or threads > 4): 313 | raise ImageScannerClientError("Thread count must be between 2 " 314 | "and 4") 315 | 316 | scan_args = self.args_tuple(allimages=allimages, images=images, 317 | allcontainers=allcontainers, 318 | onlyactive=onlyactive) 319 | 320 | # Check to make sure a scan type was selected 321 | if not scan_args.allimages and not scan_args.images and not \ 322 | scan_args.allcontainers and not scan_args.onlyactive: 323 | raise ImageScannerClientError("You must select \ 324 | a scan type") 325 | 326 | # Check to make sure only one scan type was selected 327 | if len([x for x in [scan_args.allimages, scan_args.images, 328 | scan_args.allcontainers, scan_args.onlyactive] 329 | if x is True]) > 1: 330 | raise ImageScannerClientError("You may only select one \ 331 | type of scan") 332 | # Check profile names are valid 333 | all_profile_names = self.get_all_profile_names() 334 | self._check_profile_is_valid(all_profile_names, profile_list) 335 | 336 | # Obtain list of profiles 337 | profiles = self.return_profiles(profile_list) 338 | 339 | self.num_total = len(profiles) 340 | 341 | # FIXME 342 | # Make this a variable based on desired number 343 | pool = ThreadPool(remote_threads) 344 | pool.map(self.thread_profile_wrapper, 345 | [(x, scan_args.onlyactive, scan_args.allcontainers, 346 | scan_args.allimages, scan_args.images) for x in profiles]) 347 | 348 | with open(self.uber_file_path, 'w') as state_file: 349 | json.dump(self.uber_docker, state_file) 350 | 351 | return self.uber_docker 352 | 353 | @staticmethod 354 | def _check_profile_is_valid(all_profile_names, profile_list): 355 | ''' Checks a list of profiles to make sure they are valid ''' 356 | for profile in profile_list: 357 | if profile not in all_profile_names: 358 | raise ImageScannerClientError("Profile {0} is invalid" 359 | .format(profile)) 360 | 361 | def load_uber(self): 362 | ''' Loads the uber json file''' 363 | uber_obj = json.loads(open(self.uber_file_path).read()) 364 | return uber_obj 365 | 366 | @staticmethod 367 | def _sum_cves(scan_results_obj): 368 | ''' Returns the total number of CVEs found''' 369 | num_cves = 0 370 | sev_list = ['Critical', 'Important', 'Moderate', 'Low'] 371 | for sev in sev_list: 372 | if sev in scan_results_obj.keys(): 373 | num_cves += scan_results_obj[sev]['num'] 374 | return num_cves 375 | 376 | def mult_host_mini_pprint(self, uber_obj): 377 | ''' Pretty print the results of a multi host scan''' 378 | print("\n") 379 | print("{0:16} {1:15} {2:12}".format("Host", "Docker ID", "Results")) 380 | print("-" * 50) 381 | prev_host = None 382 | for host in uber_obj.keys(): 383 | if 'error' in uber_obj[host]: 384 | print("{0:16} {1:15} {2:12}"\ 385 | .format(host, "", json.loads(uber_obj[host])['error'])) 386 | print("") 387 | continue 388 | for scan_obj in uber_obj[host]['scanned_content']: 389 | tmp_obj = uber_obj[host]['host_results'][scan_obj] 390 | is_rhel = tmp_obj['isRHEL'] 391 | if is_rhel: 392 | if len(tmp_obj['cve_summary']['scan_results'].keys()) < 1: 393 | result = "Clean" 394 | else: 395 | num_cves = self._sum_cves(tmp_obj['cve_summary'] 396 | ['scan_results']) 397 | result = "Has {0} CVEs".format(num_cves) 398 | else: 399 | result = "Not based on RHEL" 400 | if host is not prev_host: 401 | out_host = host 402 | prev_host = host 403 | else: 404 | out_host = "" 405 | print("{0:16} {1:15} {2:12}".format(out_host, scan_obj[:12], 406 | result)) 407 | print("") 408 | -------------------------------------------------------------------------------- /openscap_daemon/cve_scanner/reporter.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Brent Baude 2 | # Copyright (C) 2015 Red Hat Inc., Durham, North Carolina. 3 | # 4 | # This library is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU Lesser General Public 6 | # License as published by the Free Software Foundation; either 7 | # version 2 of the License, or (at your option) any later version. 8 | # 9 | # This library is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 12 | # Lesser General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Lesser General Public License 15 | # along with openscap-daemon. If not, see . 16 | 17 | '''Reporter Class''' 18 | 19 | import collections 20 | import os 21 | 22 | 23 | class Reporter(object): 24 | ''' Does stdout reporting ''' 25 | def __init__(self, appc): 26 | self.output = collections.namedtuple('Summary', 'iid, cid, os, sevs,' 27 | 'log, msg',) 28 | self.list_of_outputs = [] 29 | self.ac = appc 30 | self.report_dir = os.path.join(self.ac.reportdir, "reports") 31 | self.ac.docker_state = os.path.join(self.report_dir, 32 | "docker_state.json") 33 | 34 | if not os.path.exists(self.report_dir): 35 | os.mkdir(self.report_dir) 36 | self.content = "" 37 | 38 | def report_summary(self): 39 | ''' 40 | This function is the primary function to output results 41 | to stdout when running the image-scanner 42 | ''' 43 | for image in self.list_of_outputs: 44 | short_cid_list = [] 45 | image_json = {image.iid: {}} 46 | image_json[image.iid]['xml_path'] = os.path.join( 47 | self.report_dir, image.iid + ".xml") 48 | if image.msg is None: 49 | for cid in image.cid: 50 | short_cid_list.append(cid[:12]) 51 | image_json[image.iid]['cids'] = short_cid_list 52 | image_json[image.iid]['critical'] = image.sevs['Critical'] 53 | image_json[image.iid]['important'] = \ 54 | image.sevs['Important'] 55 | image_json[image.iid]['moderate'] = image.sevs['Moderate'] 56 | image_json[image.iid]['low'] = image.sevs['Low'] 57 | image_json[image.iid]['os'] = image.os 58 | else: 59 | image_json[image.iid]['msg'] = image.msg 60 | self.ac.return_json[image.iid] = image_json[image.iid] 61 | report_files = [] 62 | for image in self.list_of_outputs: 63 | if image.msg is None: 64 | short_image = image.iid[:12] + ".scap" 65 | out = open(os.path.join(self.report_dir, short_image), 'w') 66 | report_files.append(short_image) 67 | out.write(image.log) 68 | out.close() 69 | for report in report_files: 70 | os.path.join(self.report_dir, report) 71 | 72 | def _get_dtype(self, iid): 73 | ''' Returns whether the given id is an image or container ''' 74 | # Images 75 | for image in self.ac.allimages: 76 | if image['Id'].startswith(iid): 77 | return "Image" 78 | # Containers 79 | for con in self.ac.cons: 80 | if con['Id'].startswith(iid): 81 | return "Container" 82 | return None 83 | -------------------------------------------------------------------------------- /openscap_daemon/cve_scanner/scan.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Brent Baude 2 | # Copyright (C) 2015 Red Hat Inc., Durham, North Carolina. 3 | # 4 | # This library is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU Lesser General Public 6 | # License as published by the Free Software Foundation; either 7 | # version 2 of the License, or (at your option) any later version. 8 | # 9 | # This library is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 12 | # Lesser General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Lesser General Public License 15 | # along with openscap-daemon. If not, see . 16 | 17 | import os 18 | import collections 19 | import time 20 | import logging 21 | import subprocess 22 | import xml.etree.ElementTree as ET 23 | import platform 24 | import sys 25 | import bz2 26 | from threading import Lock 27 | 28 | if sys.version_info < (3,): 29 | from StringIO import StringIO 30 | else: 31 | from io import StringIO 32 | 33 | 34 | class Scan(object): 35 | 36 | # Fix race-condition in atomic mount/unmount 37 | # We don't want to do mount and unmount simultaneously 38 | _mount_lock = Lock() 39 | 40 | def __init__(self, image_uuid, con_uuids, output, appc, mnt_dir="/tmp"): 41 | self.mnt_dir = mnt_dir 42 | self.image_name = image_uuid 43 | self.ac = appc 44 | self.CVEs = collections.namedtuple('CVEs', 'title, severity,' 45 | 'cve_ref_id, cve_ref_url,' 46 | 'rhsa_ref_id, rhsa_ref_url') 47 | 48 | self.list_of_CVEs = [] 49 | self.con_uuids = con_uuids 50 | self.output = output 51 | self.report_dir = os.path.join(self.ac.workdir, "reports") 52 | if not os.path.exists(self.report_dir): 53 | os.mkdir(self.report_dir) 54 | start = time.time() 55 | from Atomic.mount import DockerMount 56 | self.DM = DockerMount(self.mnt_dir, mnt_mkdir=True) 57 | with Scan._mount_lock: 58 | self.dm_results = self.DM.mount(image_uuid) 59 | logging.debug("Created scanning chroot in {0}" 60 | " seconds".format(time.time() - start)) 61 | self.dest = self.dm_results 62 | 63 | 64 | def get_release(self): 65 | etc_release_path = os.path.join(self.dest, "rootfs", 66 | "etc/redhat-release") 67 | 68 | if not os.path.exists(etc_release_path): 69 | logging.info("{0} is not RHEL based".format(self.image_name)) 70 | return False 71 | 72 | self.os_release = open(etc_release_path).read() 73 | 74 | rhel = 'Red Hat Enterprise Linux' 75 | 76 | if rhel in self.os_release: 77 | logging.debug("{0} is {1}".format(self.image_name, 78 | self.os_release.rstrip())) 79 | return True 80 | else: 81 | logging.info("{0} is {1}".format(self.image_name, 82 | self.os_release.rstrip())) 83 | return False 84 | 85 | def scan(self): 86 | logging.debug("Scanning chroot {0}".format(self.image_name)) 87 | hostname = open("/etc/hostname").read().rstrip() 88 | os.environ["OSCAP_PROBE_ARCHITECTURE"] = platform.processor() 89 | os.environ["OSCAP_PROBE_ROOT"] = os.path.join(self.dest, "rootfs") 90 | os.environ["OSCAP_PROBE_OS_NAME"] = platform.system() 91 | os.environ["OSCAP_PROBE_OS_VERSION"] = platform.release() 92 | os.environ["OSCAP_PROBE_" 93 | "PRIMARY_HOST_NAME"] = "{0}:{1}".format(hostname, 94 | self.image_name) 95 | 96 | from oscap_docker_python.get_cve_input import getInputCVE 97 | # We only support RHEL 6|7 in containers right now 98 | osc = getInputCVE("/tmp") 99 | if "Red Hat Enterprise Linux" in self.os_release: 100 | if "7." in self.os_release: 101 | self.chroot_cve_file = os.path.join( 102 | self.ac.workdir, osc.dist_cve_name.format("7")) 103 | if "6." in self.os_release: 104 | self.chroot_cve_file = os.path.join( 105 | self.ac.workdir, osc.dist_cve_name.format("6")) 106 | cmd = ['oscap', 'oval', 'eval', '--report', 107 | os.path.join(self.report_dir, 108 | self.image_name + '.html'), 109 | '--results', 110 | os.path.join(self.report_dir, 111 | self.image_name + '.xml'), self.chroot_cve_file] 112 | 113 | logging.debug( 114 | "Starting evaluation with command '%s'.", 115 | " ".join(cmd)) 116 | 117 | try: 118 | self.result = subprocess.check_output(cmd).decode("utf-8") 119 | except Exception: 120 | pass 121 | # def capture_run(self, cmd): 122 | # ''' 123 | # Subprocess command that captures and returns the output and 124 | # return code. 125 | # ''' 126 | 127 | # r = subprocess.Popen(cmd, stdout=subprocess.PIPE, 128 | # stderr=subprocess.PIPE) 129 | # return r.communicate(), r.returncode 130 | 131 | def get_cons(self, fcons, short_iid): 132 | cons = [] 133 | for image in fcons: 134 | if image.startswith(short_iid): 135 | for con in fcons[image]: 136 | cons.append(con['uuid'][:12]) 137 | return cons 138 | 139 | def report_results(self): 140 | if not os.path.exists(self.chroot_cve_file): 141 | from openscap_daemon.cve_scanner.scanner_error import ImageScannerClientError 142 | raise ImageScannerClientError("Unable to find {0}" 143 | .format(self.chroot_cve_file)) 144 | return False 145 | cve_tree = ET.parse(bz2.BZ2File(self.chroot_cve_file)) 146 | self.cve_root = cve_tree.getroot() 147 | 148 | for line in self.result.splitlines(): 149 | split_line = line.split(':') 150 | # Not in love with how I did this 151 | # Should find a better marked to know if it is a line 152 | # a parsable line. 153 | if (len(split_line) == 5) and ('true' in split_line[4]): 154 | self._return_xml_values(line.split()[1][:-1]) 155 | 156 | sev_dict = {} 157 | sum_log = StringIO() 158 | sum_log.write("Image: {0} ({1})".format(self.image_name, 159 | self.os_release)) 160 | cons = self.get_cons(self.ac.fcons, self.image_name) 161 | sum_log.write("\nContainers based on this image ({0}): {1}\n" 162 | .format(len(cons), ", ".join(cons))) 163 | for sev in ['Critical', 'Important', 'Moderate', 'Low']: 164 | sev_counter = 0 165 | for cve in self.list_of_CVEs: 166 | if cve.severity == sev: 167 | sev_counter += 1 168 | sum_log.write("\n") 169 | fields = list(self.CVEs._fields) 170 | fields.remove('title') 171 | sum_log.write("{0}{1}: {2}\n" 172 | .format(" " * 5, "Title", 173 | getattr(cve, "title"))) 174 | 175 | for field in fields: 176 | sum_log.write("{0}{1}: {2}\n" 177 | .format(" " * 10, field.title(), 178 | getattr(cve, field))) 179 | sev_dict[sev] = sev_counter 180 | self.output.list_of_outputs.append( 181 | self.output.output(iid=self.image_name, cid=self.con_uuids, 182 | os=self.os_release, sevs=sev_dict, 183 | log=sum_log.getvalue(), msg=None)) 184 | sum_log.close() 185 | 186 | def _report_not_rhel(self, image): 187 | msg = "{0} is not based on RHEL".format(image[:8]) 188 | self.output.list_of_outputs.append( 189 | self.output.output(iid=image, cid=None, 190 | os=None, sevs=None, 191 | log=None, msg=msg)) 192 | 193 | def _return_xml_values(self, cve): 194 | cve_string = ("{http://oval.mitre.org/XMLSchema/oval-definitions-5}" 195 | "definitions/*[@id='%s']" % cve) 196 | cve_xml = self.cve_root.find(cve_string) 197 | title = cve_xml.find("{http://oval.mitre.org/XMLSchema/oval-" 198 | "definitions-5}metadata/" 199 | "{http://oval.mitre.org/XMLSchema/" 200 | "oval-definitions-5}title") 201 | cve_id = cve_xml.find("{http://oval.mitre.org/XMLSchema/" 202 | "oval-definitions-5}metadata/{http://oval.mitre." 203 | "org/XMLSchema/oval-definitions-5}reference" 204 | "[@source='CVE']") 205 | sev = (cve_xml.find("{http://oval.mitre.org/XMLSchema/oval-definitions" 206 | "-5}metadata/{http://oval.mitre.org/XMLSchema/oval" 207 | "-definitions-5}advisory/")).text 208 | 209 | if cve_id is not None: 210 | cve_ref_id = cve_id.attrib['ref_id'] 211 | cve_ref_url = cve_id.attrib['ref_url'] 212 | else: 213 | cve_ref_id = None 214 | cve_ref_url = None 215 | 216 | rhsa_id = cve_xml.find("{http://oval.mitre.org/XMLSchema/oval-" 217 | "definitions-5}metadata/{http://oval.mitre.org" 218 | "/XMLSchema/oval-definitions-5}reference" 219 | "[@source='RHSA']") 220 | 221 | if rhsa_id is not None: 222 | rhsa_ref_id = rhsa_id.attrib['ref_id'] 223 | rhsa_ref_url = rhsa_id.attrib['ref_url'] 224 | else: 225 | rhsa_ref_id = None 226 | rhsa_ref_url = None 227 | 228 | self.list_of_CVEs.append( 229 | self.CVEs(title=title.text, cve_ref_id=cve_ref_id, 230 | cve_ref_url=cve_ref_url, rhsa_ref_id=rhsa_ref_id, 231 | rhsa_ref_url=rhsa_ref_url, severity=sev)) 232 | 233 | def _get_rpms(self): 234 | # TODO: External dep! 235 | import rpm 236 | 237 | chroot_os = os.path.join(self.dest, "rootfs") 238 | ts = rpm.TransactionSet(chroot_os) 239 | ts.setVSFlags((rpm._RPMVSF_NOSIGNATURES | rpm._RPMVSF_NODIGESTS)) 240 | image_rpms = [] 241 | for hdr in ts.dbMatch(): # No sorting 242 | if hdr['name'] == 'gpg-pubkey': 243 | continue 244 | else: 245 | foo = "{0}-{1}-{2}-{3}-{4}".format(hdr['name'], 246 | hdr['epochnum'], 247 | hdr['version'], 248 | hdr['release'], 249 | hdr['arch']) 250 | image_rpms.append(foo) 251 | return image_rpms 252 | 253 | def unmount(self): 254 | with Scan._mount_lock: 255 | self.DM.unmount_path(self.dest) 256 | self.DM._clean_temp_container_by_path(self.dest) 257 | os.rmdir(self.dest) 258 | -------------------------------------------------------------------------------- /openscap_daemon/cve_scanner/scanner_client.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Brent Baude 2 | # Copyright (C) 2015 Red Hat Inc., Durham, North Carolina. 3 | # 4 | # This library is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU Lesser General Public 6 | # License as published by the Free Software Foundation; either 7 | # version 2 of the License, or (at your option) any later version. 8 | # 9 | # This library is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 12 | # Lesser General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Lesser General Public License 15 | # along with openscap-daemon. If not, see . 16 | 17 | 18 | from openscap_daemon import dbus_utils 19 | from openscap_daemon.cve_scanner.scanner_error import ImageScannerClientError 20 | 21 | import os 22 | import dbus 23 | import dbus.mainloop.glib 24 | import json 25 | import collections 26 | import docker 27 | 28 | # TODO: external dep! 29 | from slip.dbus import polkit 30 | 31 | 32 | class Client(object): 33 | ''' The image-scanner client API ''' 34 | 35 | image_tmp = "/var/tmp/image-scanner" 36 | db_timeout = 99999 37 | tup_names = ['number', 'workdir', 'logfile', 'onlycache', 38 | 'reportdir'] 39 | tup = collections.namedtuple('args', tup_names) 40 | 41 | def __init__(self, number=2, 42 | logfile=os.path.join(image_tmp, "openscap.log"), 43 | onlycache=False, 44 | reportdir=image_tmp, workdir=image_tmp): 45 | 46 | self.arg_tup = self.tup(number=number, logfile=logfile, 47 | onlycache=onlycache, reportdir=reportdir, 48 | workdir=workdir) 49 | 50 | self.arg_dict = {'number': number, 'logfile': logfile, 51 | 'onlycache': onlycache, 'reportdir': reportdir, 52 | 'workdir': workdir} 53 | self._docker_ping() 54 | self.num_threads = number 55 | self.bus = dbus.SessionBus() 56 | self.dbus_object = self.bus.get_object(dbus_utils.BUS_NAME, 57 | dbus_utils.OBJECT_PATH) 58 | self.logfile = logfile 59 | self.onlycache = onlycache 60 | self.reportdir = reportdir 61 | self.workdir = workdir 62 | self.onlyactive = False 63 | self.allcontainers = False 64 | self.allimages = False 65 | self.images = False 66 | 67 | @staticmethod 68 | def _docker_ping(): 69 | # Class docker.Client was renamed to docker.APIClient in 70 | # python-docker-py 2.0.0. 71 | try: 72 | d_conn = docker.APIClient() 73 | except AttributeError: 74 | d_conn = docker.Client() 75 | try: 76 | d_conn.ping() 77 | except Exception: 78 | raise ImageScannerClientError("The docker daemon does not appear" 79 | "to be running") 80 | 81 | @polkit.enable_proxy 82 | def inspect_container(self, cid): 83 | ret = self.dbus_object.inspect_container( 84 | cid, 85 | dbus_interface=dbus_utils.DBUS_INTERFACE, 86 | timeout=self.db_timeout 87 | ) 88 | return json.loads(ret) 89 | 90 | @polkit.enable_proxy 91 | def get_images_info(self): 92 | ret = self.dbus_object.images( 93 | dbus_interface=dbus_utils.DBUS_INTERFACE, 94 | timeout=self.db_timeout 95 | ) 96 | return json.loads(ret) 97 | 98 | @polkit.enable_proxy 99 | def get_containers_info(self): 100 | ret = self.dbus_object.containers( 101 | dbus_interface=dbus_utils.DBUS_INTERFACE, 102 | timeout=self.db_timeout 103 | ) 104 | return json.loads(ret) 105 | 106 | @polkit.enable_proxy 107 | def inspect_image(self, iid): 108 | ret = self.dbus_object.inspect_image( 109 | iid, 110 | dbus_interface=dbus_utils.DBUS_INTERFACE, 111 | timeout=self.db_timeout 112 | ) 113 | return json.loads(ret) 114 | 115 | def debug_json(self, json_data): 116 | ''' Debug function that pretty prints json objects''' 117 | print(json.dumps(json_data, indent=4, separators=(',', ': '))) 118 | 119 | @polkit.enable_proxy 120 | def scan_containers(self, only_active=False): 121 | if only_active: 122 | self.onlyactive = True 123 | else: 124 | self.allcontainers = True 125 | 126 | ret = self.dbus_object.scan_containers( 127 | self.onlyactive, 128 | self.allcontainers, 129 | self.num_threads, 130 | dbus_interface=dbus_utils.DBUS_INTERFACE, 131 | timeout=self.db_timeout 132 | ) 133 | return json.loads(ret) 134 | 135 | @polkit.enable_proxy 136 | def scan_images(self, all_images=False): 137 | if all_images: 138 | self.allimages = True 139 | else: 140 | self.images = True 141 | 142 | ret = self.dbus_object.scan_images( 143 | self.allimages, self.images, 144 | self.num_threads, 145 | dbus_interface=dbus_utils.DBUS_INTERFACE, 146 | timeout=self.db_timeout 147 | ) 148 | return json.loads(ret) 149 | 150 | @polkit.enable_proxy 151 | def scan_list(self, scan_list): 152 | if not isinstance(scan_list, list): 153 | raise ImageScannerClientError("Input to scan_list must be in" 154 | "the form of a list") 155 | ret = self.dbus_object.scan_list( 156 | scan_list, self.num_threads, 157 | dbus_interface=dbus_utils.DBUS_INTERFACE, 158 | timeout=self.db_timeout 159 | ) 160 | return json.loads(ret) 161 | -------------------------------------------------------------------------------- /openscap_daemon/cve_scanner/scanner_error.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Brent Baude 2 | # Copyright (C) 2015 Red Hat Inc., Durham, North Carolina. 3 | # 4 | # This library is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU Lesser General Public 6 | # License as published by the Free Software Foundation; either 7 | # version 2 of the License, or (at your option) any later version. 8 | # 9 | # This library is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 12 | # Lesser General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Lesser General Public License 15 | # along with openscap-daemon. If not, see . 16 | 17 | 18 | import dbus 19 | 20 | 21 | class ImageScannerClientError(dbus.DBusException): 22 | """ImageScanner error""" 23 | dbus_error_name = 'org.atomic.Exception' 24 | -------------------------------------------------------------------------------- /openscap_daemon/dbus_utils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Red Hat Inc., Durham, North Carolina. 2 | # All Rights Reserved. 3 | # 4 | # openscap-daemon is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Lesser General Public License as published by 6 | # the Free Software Foundation, either version 2.1 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # openscap-daemon is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Lesser General Public License for more details. 13 | 14 | # You should have received a copy of the GNU Lesser General Public License 15 | # along with openscap-daemon. If not, see . 16 | # 17 | # Authors: 18 | # Martin Preisler 19 | 20 | import os 21 | 22 | OBJECT_PATH = "/OpenSCAP/daemon" 23 | DBUS_INTERFACE = "org.OpenSCAP.daemon.Interface" 24 | BUS_NAME = "org.OpenSCAP.daemon" 25 | 26 | 27 | def get_dbus(): 28 | import dbus 29 | 30 | var_name = "OSCAPD_SESSION_BUS" 31 | if var_name in os.environ and os.environ[var_name] == "1": 32 | return dbus.SessionBus() 33 | 34 | return dbus.SystemBus() 35 | -------------------------------------------------------------------------------- /openscap_daemon/et_helpers.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Red Hat Inc., Durham, North Carolina. 2 | # All Rights Reserved. 3 | # 4 | # openscap-daemon is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Lesser General Public License as published by 6 | # the Free Software Foundation, either version 2.1 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # openscap-daemon is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Lesser General Public License for more details. 13 | 14 | # You should have received a copy of the GNU Lesser General Public License 15 | # along with openscap-daemon. If not, see . 16 | # 17 | # Authors: 18 | # Martin Preisler 19 | 20 | 21 | def get_element_text(parent, element_name, default=None): 22 | ret = None 23 | for element in parent.findall(element_name): 24 | if ret is not None: 25 | raise RuntimeError( 26 | "Found multiple '%s' elements." % (element_name) 27 | ) 28 | 29 | ret = element.text 30 | 31 | if ret is None: 32 | return default 33 | 34 | return ret 35 | 36 | 37 | def get_element(parent, element_name): 38 | ret = None 39 | for element in parent.findall(element_name): 40 | if ret is not None: 41 | raise RuntimeError( 42 | "Found multiple '%s' elements." % 43 | (element_name) 44 | ) 45 | 46 | ret = element 47 | 48 | if ret is None: 49 | raise RuntimeError( 50 | "Found no element of tag '%s'!" % 51 | (element_name) 52 | ) 53 | 54 | return ret 55 | 56 | 57 | def get_element_attr(parent, element_name, attr_name, default=None): 58 | ret = None 59 | for element in parent.findall(element_name): 60 | if ret is not None: 61 | raise RuntimeError( 62 | "Found multiple '%s' elements with '%s' attributes." % 63 | (element_name, attr_name) 64 | ) 65 | 66 | ret = element.get(attr_name) 67 | 68 | if ret is None: 69 | return default 70 | 71 | return ret 72 | 73 | 74 | # taken from ElementLib and slightly tweaked for readability 75 | def indent(elem, level=0, indent_char=" "): 76 | i = "\n" + level * indent_char 77 | if len(elem): 78 | if not elem.text or not elem.text.strip(): 79 | elem.text = i + indent_char 80 | 81 | last = None 82 | for e in elem: 83 | indent(e, level + 1) 84 | if not e.tail or not e.tail.strip(): 85 | e.tail = i + indent_char 86 | 87 | last = e 88 | 89 | if not last.tail or not last.tail.strip(): 90 | last.tail = i 91 | else: 92 | if level and (not elem.tail or not elem.tail.strip()): 93 | elem.tail = i 94 | -------------------------------------------------------------------------------- /openscap_daemon/oscap_helpers.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Red Hat Inc., Durham, North Carolina. 2 | # All Rights Reserved. 3 | # 4 | # openscap-daemon is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Lesser General Public License as published by 6 | # the Free Software Foundation, either version 2.1 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # openscap-daemon is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Lesser General Public License for more details. 13 | 14 | # You should have received a copy of the GNU Lesser General Public License 15 | # along with openscap-daemon. If not, see . 16 | # 17 | # Authors: 18 | # Martin Preisler 19 | 20 | 21 | import subprocess 22 | import tempfile 23 | import os.path 24 | import logging 25 | import io 26 | 27 | try: 28 | import xml.etree.cElementTree as ElementTree 29 | except ImportError: 30 | import xml.etree.ElementTree as ElementTree 31 | from openscap_daemon import et_helpers 32 | from openscap_daemon.compat import subprocess_check_output 33 | 34 | 35 | class EvaluationMode(object): 36 | UNKNOWN = -1 37 | 38 | SOURCE_DATASTREAM = 1 39 | OVAL = 2 40 | CVE_SCAN = 3 41 | STANDARD_SCAN = 4 42 | 43 | @staticmethod 44 | def to_string(value): 45 | if value == EvaluationMode.SOURCE_DATASTREAM: 46 | return "sds" 47 | elif value == EvaluationMode.OVAL: 48 | return "oval" 49 | elif value == EvaluationMode.CVE_SCAN: 50 | return "cve_scan" 51 | elif value == EvaluationMode.STANDARD_SCAN: 52 | return "standard_scan" 53 | 54 | else: 55 | return "unknown" 56 | 57 | @staticmethod 58 | def from_string(value): 59 | if value == "sds": 60 | return EvaluationMode.SOURCE_DATASTREAM 61 | elif value == "oval": 62 | return EvaluationMode.OVAL 63 | elif value == "cve_scan": 64 | return EvaluationMode.CVE_SCAN 65 | elif value == "standard_scan": 66 | return EvaluationMode.STANDARD_SCAN 67 | 68 | else: 69 | return EvaluationMode.UNKNOWN 70 | 71 | 72 | def get_profile_choices_for_input(input_file, tailoring_file, xccdf_id): 73 | # Ideally oscap would have a command line to do this, but as of now it 74 | # doesn't so we have to implement it ourselves. Importing openscap Python 75 | # bindings is nasty and overkill for this. 76 | 77 | logging.debug( 78 | "Looking for profile choices in '%s' with tailoring file '%s'.", 79 | input_file, tailoring_file 80 | ) 81 | 82 | ret = {} 83 | 84 | def scrape_profiles(tree, xccdf_id, xccdf_ns, profile_ns, dest): 85 | xlink_href = "{http://www.w3.org/1999/xlink}href" 86 | xccdfs = [] 87 | 88 | if xccdf_id is None: 89 | # If xccdf_id is not specified look for profiles only in the first 90 | # xccdf component found in the datastream. 91 | xccdfs = tree.findall( 92 | ".//{%s}checklists/{%s}component-ref[1]" % (xccdf_ns, xccdf_ns) 93 | ) 94 | else: 95 | xccdfs = tree.findall( 96 | ".//{%s}checklists/{%s}component-ref/[@id='%s']" 97 | % (xccdf_ns, xccdf_ns, xccdf_id) 98 | ) 99 | 100 | for x in xccdfs: 101 | c = x.attrib[xlink_href] 102 | c = c[1:] # Removes starting '#' character. 103 | for elem in tree.findall(".//{%s}component/[@id='%s']//{%s}Profile" 104 | % (xccdf_ns, c, profile_ns)): 105 | id_ = elem.get("id") 106 | title = et_helpers.get_element_text( 107 | elem, "{%s}title" % (profile_ns), "" 108 | ) 109 | dest[id_] = title 110 | 111 | try: 112 | input_tree = ElementTree.parse(input_file) 113 | 114 | except IOError: 115 | # The file doesn't exist, therefore there are no profile options 116 | logging.exception( 117 | "IOError encountered while trying to determine profile choices " 118 | "for '%s'.", input_file 119 | ) 120 | return ret 121 | 122 | except ElementTree.ParseError: 123 | logging.exception( 124 | "ParserError encountered while trying to determine profile choices " 125 | "for '%s'.", input_file 126 | ) 127 | return ret 128 | 129 | scrape_profiles( 130 | input_tree, 131 | xccdf_id, 132 | "http://scap.nist.gov/schema/scap/source/1.1", 133 | "http://checklists.nist.gov/xccdf/1.1", 134 | ret 135 | ) 136 | scrape_profiles( 137 | input_tree, 138 | xccdf_id, 139 | "http://scap.nist.gov/schema/scap/source/1.2", 140 | "http://checklists.nist.gov/xccdf/1.2", 141 | ret 142 | ) 143 | 144 | if tailoring_file: 145 | tailoring_tree = ElementTree.parse(tailoring_file) 146 | 147 | scrape_profiles( 148 | tailoring_tree, 149 | None, 150 | "http://scap.nist.gov/schema/scap/source/1.1", 151 | "http://checklists.nist.gov/xccdf/1.1", 152 | ret 153 | ) 154 | scrape_profiles( 155 | tailoring_tree, 156 | None, 157 | "http://scap.nist.gov/schema/scap/source/1.2", 158 | "http://checklists.nist.gov/xccdf/1.2", 159 | ret 160 | ) 161 | 162 | ret[""] = "(default)" 163 | 164 | logging.info( 165 | "Found %i profile choices in '%s' with tailoring file '%s'.", 166 | len(ret), input_file, tailoring_file 167 | ) 168 | 169 | return ret 170 | 171 | 172 | def get_generate_guide_args(spec, config): 173 | ret = [config.oscap_path, "xccdf", "generate", "guide"] 174 | ret.extend(spec.get_oscap_guide_arguments(config)) 175 | 176 | return ret 177 | 178 | 179 | def generate_guide(spec, config): 180 | if spec.mode not in [EvaluationMode.SOURCE_DATASTREAM, 181 | EvaluationMode.STANDARD_SCAN]: 182 | raise RuntimeError( 183 | "Can't generate guide for an EvaluationSpec with mode '%s'. " 184 | "Generating an HTML guide only works for 'sds' and 'standard_scan' " 185 | "modes." 186 | % (EvaluationMode.to_string(spec.mode)) 187 | ) 188 | 189 | if not spec.is_valid(): 190 | raise RuntimeError( 191 | "Can't generate guide for an invalid EvaluationSpec." 192 | ) 193 | 194 | args = get_generate_guide_args(spec, config) 195 | 196 | logging.debug( 197 | "Generating guide for evaluation spec with command '%s'.", 198 | " ".join(args) 199 | ) 200 | 201 | ret = subprocess_check_output( 202 | args, 203 | shell=False 204 | ).decode("utf-8") 205 | 206 | logging.info("Generated guide for evaluation spec.") 207 | 208 | return ret 209 | 210 | 211 | def split_ssh_target(target): 212 | if not target.startswith("ssh://") and not target.startswith("ssh+sudo://"): 213 | raise RuntimeError( 214 | "Can't split ssh target." 215 | ) 216 | 217 | if target.startswith("ssh+sudo://"): 218 | without_prefix = target[11:] 219 | else: 220 | without_prefix = target[6:] 221 | 222 | if ":" in without_prefix: 223 | host, port_str = without_prefix.split(":") 224 | return host, int(port_str) 225 | 226 | else: 227 | return without_prefix, 22 228 | 229 | 230 | def get_evaluation_args(spec, config): 231 | ret = [] 232 | 233 | if spec.target == "localhost": 234 | if config.oscap_path == "": 235 | raise RuntimeError( 236 | "Target '%s' requires the oscap tool which hasn't been found" % 237 | (spec.target) 238 | ) 239 | ret.extend([config.oscap_path]) 240 | 241 | elif spec.target.startswith("ssh://"): 242 | if config.oscap_ssh_path == "": 243 | raise RuntimeError( 244 | "Target '%s' requires the oscap-ssh tool which hasn't been " 245 | "found" % (spec.target) 246 | ) 247 | host, port = split_ssh_target(spec.target) 248 | ret.extend([config.oscap_ssh_path, host, str(port)]) 249 | 250 | elif spec.target.startswith("ssh+sudo://"): 251 | if config.oscap_ssh_path == "": 252 | raise RuntimeError( 253 | "Target '%s' requires the oscap-ssh tool which hasn't been " 254 | "found" % (spec.target) 255 | ) 256 | host, port = split_ssh_target(spec.target) 257 | ret.extend([config.oscap_ssh_path, '--sudo', host, str(port)]) 258 | 259 | elif spec.target.startswith("docker-image://"): 260 | if config.oscap_ssh_path == "": 261 | raise RuntimeError( 262 | "Target '%s' requires the oscap-docker tool which hasn't been " 263 | "found" % (spec.target) 264 | ) 265 | image_name = spec.target[len("docker-image://"):] 266 | ret.extend([config.oscap_docker_path, "image", image_name]) 267 | 268 | elif spec.target.startswith("docker-container://"): 269 | if config.oscap_ssh_path == "": 270 | raise RuntimeError( 271 | "Target '%s' requires the oscap-docker tool which hasn't been " 272 | "found" % (spec.target) 273 | ) 274 | container_name = spec.target[len("docker-container://"):] 275 | ret.extend([config.oscap_docker_path, "container", container_name]) 276 | 277 | elif spec.target.startswith("vm-domain://"): 278 | if config.oscap_vm_path == "": 279 | raise RuntimeError( 280 | "Target '%s' requires the oscap-vm tool which hasn't been " 281 | "found" % (spec.target) 282 | ) 283 | domain_name = spec.target[len("vm-domain://"):] 284 | ret.extend([config.oscap_vm_path, "domain", domain_name]) 285 | 286 | elif spec.target.startswith("vm-image://"): 287 | if config.oscap_vm_path == "": 288 | raise RuntimeError( 289 | "Target '%s' requires the oscap-vm tool which hasn't been " 290 | "found" % (spec.target) 291 | ) 292 | storage_name = spec.target[len("vm-image://"):] 293 | ret.extend([config.oscap_vm_path, "image", storage_name]) 294 | 295 | elif spec.target.startswith("chroot://"): 296 | if config.oscap_chroot_path == "": 297 | raise RuntimeError( 298 | "Target '%s' requires the oscap-chroot tool which hasn't been " 299 | "found" % (spec.target) 300 | ) 301 | path = spec.target[len("chroot://"):] 302 | ret.extend([config.oscap_chroot_path, path]) 303 | 304 | else: 305 | raise RuntimeError( 306 | "Unrecognized target '%s' in evaluation spec." % (spec.target) 307 | ) 308 | 309 | ret.extend(spec.get_oscap_arguments(config)) 310 | return ret 311 | 312 | 313 | def evaluate(spec, config): 314 | """Calls oscap to evaluate given task, creates a uniquely named directory 315 | in given results_dir for it. Returns absolute path to that directory in 316 | case of success. 317 | 318 | Throws exception in case of failure. 319 | """ 320 | 321 | if not spec.is_valid(): 322 | raise RuntimeError("Can't evaluate an invalid EvaluationSpec.") 323 | 324 | working_directory = tempfile.mkdtemp( 325 | prefix="", suffix="", 326 | dir=config.work_in_progress_dir 327 | ) 328 | 329 | stdout_file = io.open(os.path.join(working_directory, "stdout"), "w", 330 | encoding="utf-8") 331 | stderr_file = io.open(os.path.join(working_directory, "stderr"), "w", 332 | encoding="utf-8") 333 | 334 | args = get_evaluation_args(spec, config) 335 | 336 | logging.debug( 337 | "Starting evaluation with command '%s'.", 338 | " ".join(args) 339 | ) 340 | 341 | exit_code = 1 342 | 343 | try: 344 | exit_code = subprocess.call( 345 | args, 346 | cwd=working_directory, 347 | stdout=stdout_file, 348 | stderr=stderr_file, 349 | shell=False 350 | ) 351 | 352 | except: 353 | logging.exception( 354 | "Failed to execute 'oscap' while evaluating EvaluationSpec." 355 | ) 356 | 357 | stdout_file.flush() 358 | stderr_file.flush() 359 | 360 | with io.open(os.path.join(working_directory, "exit_code"), "w", 361 | encoding="utf-8") as f: 362 | f.write(u"%i" % (exit_code)) 363 | 364 | # Exit code 0 means evaluation was successful and machine is compliant. 365 | # Exit code 1 means there was an error while evaluating. 366 | # Exit code 2 means there were no errors but the machine is not compliant. 367 | 368 | if exit_code == 0: 369 | logging.info( 370 | "Evaluated EvaluationSpec, exit_code=0." 371 | ) 372 | # TODO: Assert that arf was generated 373 | 374 | elif exit_code == 2: 375 | logging.warning( 376 | "Evaluated EvaluationSpec, exit_code=2." 377 | ) 378 | # TODO: Assert that arf was generated 379 | 380 | elif exit_code == 1: 381 | logging.error( 382 | "EvaluationSpec failed to evaluate, oscap returned 1 as exit code, " 383 | "it may not be possible to get ARF/OVAL results or generate reports" 384 | " for this result!" 385 | ) 386 | # TODO: Assert that arf was NOT generated 387 | 388 | else: 389 | logging.error( 390 | "Evaluated EvaluationSpec, unknown exit code %i!.", exit_code 391 | ) 392 | 393 | return working_directory 394 | 395 | 396 | def get_generate_report_args_for_results(spec, results_path, config): 397 | if spec.mode == EvaluationMode.SOURCE_DATASTREAM: 398 | # results_path is an ARF XML file 399 | return [config.oscap_path, "xccdf", "generate", "report", results_path] 400 | 401 | elif spec.mode == EvaluationMode.OVAL: 402 | # results_path is an OVAL results XML file 403 | return [config.oscap_path, "oval", "generate", "report", results_path] 404 | 405 | elif spec.mode == EvaluationMode.CVE_SCAN: 406 | # results_path is an OVAL results XML file 407 | return [config.oscap_path, "oval", "generate", "report", results_path] 408 | 409 | elif spec.mode == EvaluationMode.STANDARD_SCAN: 410 | # results_path is an ARF XML file 411 | return [config.oscap_path, "xccdf", "generate", "report", results_path] 412 | 413 | else: 414 | raise RuntimeError("Unknown evaluation mode") 415 | 416 | 417 | def generate_report_for_result(spec, results_dir, result_id, config): 418 | """This function assumes that the ARF was generated using evaluate 419 | in this same package. That's why we can avoid --datastream-id, ... 420 | 421 | The behavior is undefined for generic ARFs! 422 | """ 423 | 424 | if not spec.is_valid(): 425 | raise RuntimeError("Can't generate report for any result of an " 426 | "invalid EvaluationSpec.") 427 | 428 | results_path = os.path.join(results_dir, str(result_id), "results.xml") 429 | 430 | if not os.path.exists(results_path): 431 | raise RuntimeError("Can't generate report for result '%s'. Expected " 432 | "results XML at '%s' but the file doesn't exist." 433 | % (result_id, results_path)) 434 | 435 | args = get_generate_report_args_for_results(spec, results_path, config) 436 | 437 | logging.debug( 438 | "Generating report for result %i of EvaluationSpec with command '%s'.", 439 | result_id, " ".join(args) 440 | ) 441 | 442 | ret = subprocess_check_output( 443 | args, 444 | shell=False 445 | ).decode("utf-8") 446 | 447 | logging.info( 448 | "Generated report for result %i of EvaluationSpec.", result_id 449 | ) 450 | 451 | return ret 452 | 453 | 454 | def get_status_from_exit_code(exit_code): 455 | """Returns human readable status based on given `oscap` exit_code 456 | """ 457 | 458 | status = "Unknown (exit_code = %i)" % (exit_code) 459 | if exit_code == 0: 460 | status = "Compliant" 461 | elif exit_code == 1: 462 | status = "Evaluation Error" 463 | elif exit_code == 2: 464 | status = "Non-Compliant" 465 | 466 | return status 467 | 468 | 469 | def _fix_type_to_template(fix_type): 470 | fix_templates = {"bash": "urn:xccdf:fix:script:sh", 471 | "ansible": "urn:xccdf:fix:script:ansible", 472 | "puppet": "urn:xccdf:fix:script:puppet"} 473 | template = fix_templates[fix_type] 474 | return template 475 | 476 | 477 | def _get_result_id(results_path): 478 | tree = ElementTree.parse(results_path) 479 | root = tree.getroot() 480 | ns = {"xccdf": "http://checklists.nist.gov/xccdf/1.2"} 481 | test_result = root.find(".//xccdf:TestResult", ns) 482 | if test_result is None: 483 | raise RuntimeError("Results XML '%s' doesn't contain any results." 484 | % results_path) 485 | return test_result.attrib["id"] 486 | 487 | 488 | def generate_fix_for_result(config, results_path, fix_type, xccdf_id=None): 489 | if not os.path.exists(results_path): 490 | raise RuntimeError("Can't generate fix for scan result. Expected " 491 | "results XML at '%s' but the file doesn't exist." 492 | % results_path) 493 | result_id = _get_result_id(results_path) 494 | template = _fix_type_to_template(fix_type) 495 | args = [config.oscap_path, "xccdf", "generate", "fix", 496 | "--result-id", result_id, 497 | "--template", template] 498 | if xccdf_id is not None: 499 | args.extend(["--xccdf-id", xccdf_id]) 500 | args.append(results_path) 501 | fix_text = subprocess_check_output(args).decode("utf-8") 502 | return fix_text 503 | 504 | 505 | def generate_html_report_for_result(config, results_path): 506 | if not os.path.exists(results_path): 507 | raise RuntimeError("Can't generate report for scan result. Expected " 508 | "results XML at '%s' but the file doesn't exist." 509 | % results_path) 510 | result_id = _get_result_id(results_path) 511 | args = [config.oscap_path, "xccdf", "generate", "report", 512 | "--result-id", result_id, results_path] 513 | report_text = subprocess_check_output(args).decode("utf-8") 514 | return report_text 515 | 516 | 517 | def generate_fix(spec, config, fix_type): 518 | if spec.mode not in [EvaluationMode.SOURCE_DATASTREAM, 519 | EvaluationMode.STANDARD_SCAN]: 520 | raise RuntimeError( 521 | "Can't generate fix for an EvaluationSpec with mode '%s'. " 522 | "Generating a fix script only works for 'sds' and 'standard_scan' " 523 | "modes." 524 | % (EvaluationMode.to_string(spec.mode)) 525 | ) 526 | 527 | if not spec.is_valid(): 528 | raise RuntimeError( 529 | "Can't generate fix for an invalid EvaluationSpec." 530 | ) 531 | 532 | template = _fix_type_to_template(fix_type) 533 | args = [config.oscap_path, "xccdf", "generate", "fix", 534 | "--profile", spec.profile_id, 535 | "--template", template, 536 | spec.input_.file_path] 537 | 538 | logging.debug( 539 | "Generating fix script for evaluation spec with command '%s'.", 540 | " ".join(args) 541 | ) 542 | 543 | ret = subprocess_check_output(args).decode("utf-8") 544 | 545 | logging.info("Generated fix script for evaluation spec.") 546 | 547 | return ret 548 | 549 | 550 | def schedule_repeat_after(schedule_str): 551 | schedule = None 552 | if schedule_str == "@daily": 553 | schedule = 1 * 24 554 | elif schedule_str == "@weekly": 555 | schedule = 7 * 24 556 | elif schedule_str == "@monthly": 557 | schedule = 30 * 24 558 | else: 559 | schedule = int(schedule_str) 560 | return schedule 561 | 562 | __all__ = [ 563 | "get_profile_choices_for_input", 564 | "generate_guide", 565 | "generate_fix", 566 | "evaluate", 567 | "generate_report_for_result", 568 | "get_status_from_exit_code", 569 | "generate_fix_for_result", 570 | "schedule_repeat_after" 571 | ] 572 | -------------------------------------------------------------------------------- /openscap_daemon/rest_api.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Red Hat Inc., Durham, North Carolina. 2 | # All Rights Reserved. 3 | # 4 | # openscap-daemon is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Lesser General Public License as published by 6 | # the Free Software Foundation, either version 2.1 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # openscap-daemon is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Lesser General Public License for more details. 13 | 14 | # You should have received a copy of the GNU Lesser General Public License 15 | # along with openscap-daemon. If not, see . 16 | # 17 | # Authors: 18 | # Mario Vazquez 19 | 20 | import threading 21 | import json 22 | import os 23 | 24 | from openscap_daemon import oscap_helpers 25 | from datetime import datetime 26 | from flask import Flask, request 27 | 28 | 29 | class OpenSCAPRestApi(object): 30 | """Internal class that implements the REST API using Flask""" 31 | def __init__(self, system_instance): 32 | super(OpenSCAPRestApi, self).__init__() 33 | 34 | self.app = Flask(__name__) 35 | self.system = system_instance 36 | 37 | self.system.load_tasks() 38 | 39 | self.api_worker_thread = threading.Thread( 40 | target=lambda: self.run() 41 | ) 42 | self.api_worker_thread.daemon = True 43 | self.api_worker_thread.start() 44 | 45 | def run(self): 46 | """Configures API Endpoints and runs the flask app""" 47 | self.app.add_url_rule("/tasks/", 48 | "get_all_tasks", self.get_task, 49 | methods=['GET']) 50 | self.app.add_url_rule("/tasks//", 51 | "get_task", self.get_task, 52 | methods=['GET']) 53 | self.app.add_url_rule("/tasks/", 54 | "new_task", self.new_task, 55 | methods=['POST']) 56 | self.app.add_url_rule("/tasks//", 57 | "update_task", self.update_task, 58 | methods=['PUT']) 59 | self.app.add_url_rule("/tasks//guide/", 60 | "get_task_guide", self.get_task_guide, 61 | methods=['GET']) 62 | self.app.add_url_rule("/tasks//result//", 63 | "get_task_result", self.get_task_result, 64 | methods=['GET']) 65 | self.app.add_url_rule("/tasks//result/", 66 | "remove_all_task_results", self.remove_task_result, 67 | methods=['DELETE']) 68 | self.app.add_url_rule("/tasks//result//", 69 | "remove_task_result", self.remove_task_result, 70 | methods=['DELETE']) 71 | self.app.add_url_rule("/tasks//run/", 72 | "run_task_outside_schedule", self.run_task_outside_schedule, 73 | methods=['GET']) 74 | self.app.add_url_rule("/tasks//", 75 | "remove_task", self.remove_task, 76 | methods=['DELETE']) 77 | self.app.add_url_rule("/tasks//results/", 78 | "remove_task_and_results", self.remove_task, 79 | methods=['DELETE'], defaults={'remove_results': True}) 80 | self.app.add_url_rule("/tasks///", 81 | "task_schedule", self.task_schedule, 82 | methods=['PUT']) 83 | self.app.add_url_rule("/ssgs/", 84 | "get_ssg", self.get_ssg, 85 | methods=['GET', 'POST']) 86 | if self.system.config.rest_debug: 87 | self.app.debug = True 88 | 89 | self.app.run( 90 | host=str(self.system.config.rest_host), 91 | port=int(self.system.config.rest_port), 92 | use_reloader=False 93 | ) 94 | 95 | def get_ssg(self, ssg_file="system", tailoring_file=None): 96 | """Returns a list of SSG with its profiles""" 97 | if request.method == "POST": 98 | content = request.get_json(silent=True) 99 | required_fields = {'ssgFile', 'tailoringFile'} 100 | if content is None: 101 | return '{"Error" : "json data required"}', 400 102 | elif not required_fields <= set(content): 103 | return '{"Error": "There are missing fields in the request"}', 400 104 | elif content['ssgFile'] == "": 105 | return '{"Error": "ssgFile field cannot be empty"}', 400 106 | else: 107 | ssg_file = content['ssgFile'] 108 | if content['tailoringFile']: 109 | tailoring_file = content['tailoringFile'] 110 | if ssg_file == "system": 111 | ssg_choices = self.system.get_ssg_choices() 112 | else: 113 | ssg_choices = [ssg_file] 114 | ssgs = [] 115 | for ssg_choice in ssg_choices: 116 | ssg_file = os.path.abspath(ssg_choice) 117 | if tailoring_file in [None, ""]: 118 | tailoring_file = "" 119 | else: 120 | tailoring_file = os.path.abspath(tailoring_file) 121 | profiles = self.system.get_profile_choices_for_input(ssg_file, tailoring_file) 122 | ssg_profile = [] 123 | if len(profiles) > 0: 124 | for profile_id, profile_name in profiles.items(): 125 | ssg_profile.append({'profileId': profile_id, 'profileName': profile_name}) 126 | ssgs.append({'ssgFile': ssg_file, 'tailoringFile': tailoring_file, 'profiles': ssg_profile}) 127 | else: 128 | ssgs.append({'ssgFile': ssg_file, 'tailoringFile': tailoring_file, 'profiles': 'Either ssgFile or tailoringFile does not exists'}) 129 | ssgs_json = '{"ssgs":' + json.dumps(ssgs, indent=4) + '}' 130 | return ssgs_json 131 | 132 | def task_schedule(self, task_id, schedule): 133 | """Updates the task schedule""" 134 | status = [] 135 | if schedule == "enable": 136 | self.system.set_task_enabled(task_id, True) 137 | elif schedule == "disable": 138 | self.system.set_task_enabled(task_id, False) 139 | else: 140 | schedule = "not_modified" 141 | status.append({'id': task_id, 'schedule': schedule}) 142 | schedule_json = '{"tasks":' + json.dumps(status, indent=4) + '}' 143 | return schedule_json 144 | 145 | def remove_task(self, task_id, remove_results=False): 146 | """Removes tasks from OpenSCAP Daemon""" 147 | delete = [] 148 | try: 149 | task_enabled = self.system.get_task_enabled(task_id) 150 | if task_enabled: 151 | delete.append({'id': task_id, 'removed': 'enabled tasks cannot be deleted'}) 152 | else: 153 | self.system.remove_task(task_id, remove_results) 154 | delete.append({'id': task_id, 'removed': 'true'}) 155 | except RuntimeError as err: 156 | delete.append({'id': task_id, 'removed': str(err)}) 157 | except KeyError: 158 | delete.append({'id': task_id, 'removed': 'task not found'}) 159 | remove_json = '{"tasks":' + json.dumps(delete, indent=4) + '}' 160 | return remove_json 161 | 162 | def run_task_outside_schedule(self, task_id): 163 | """Forces the launch of tasks""" 164 | run = [] 165 | try: 166 | task_enabled = self.system.get_task_enabled(task_id) 167 | if task_enabled: 168 | try: 169 | self.system.run_task_outside_schedule(task_id) 170 | run.append({'id': task_id, 'running': 'true'}) 171 | except RuntimeError as err: 172 | run.append({'id': task_id, 'running': str(err)}) 173 | else: 174 | run.append({'id': task_id, 'running': 'Task must be enabled first'}) 175 | except KeyError: 176 | return '{"Error" : "Task not found"}' 177 | run_json = '{"tasks": ' + json.dumps(run, indent=4) + '}' 178 | return run_json 179 | 180 | def remove_task_result(self, task_id, result_id="all"): 181 | """Removes task results from tasks""" 182 | remove = [] 183 | task_results = [] 184 | if result_id == "all": 185 | self.system.remove_task_results(task_id) 186 | else: 187 | self.system.remove_task_result(task_id, result_id) 188 | task_results.append({'taskResultId': str(result_id), 'removed': 'true'}) 189 | remove.append({'id': str(task_id), 'taskResultsRemoved': task_results}) 190 | remove_json = '{"tasks": ' + json.dumps(remove, indent=4) + '}' 191 | return remove_json 192 | 193 | def get_task_result(self, task_id, result_id): 194 | """Returns the task Result information in html format""" 195 | result_html = None 196 | try: 197 | result_html = self.system.generate_report_for_task_result(task_id, result_id) 198 | except (RuntimeError, KeyError): 199 | result_html = '{"Error" : "HTML Report could not been generated. Please, check that task and result ids exists"}' 200 | return result_html 201 | 202 | def get_task_guide(self, task_id): 203 | """Returns the task Guide information in html format""" 204 | guide_html = None 205 | try: 206 | guide_html = self.system.generate_guide_for_task(task_id) 207 | except (RuntimeError, KeyError): 208 | guide_html = '{"Error" : "HTML Guide could not been generated. Please, check that task id exists"}' 209 | return guide_html 210 | 211 | def update_task(self, task_id): 212 | """Updates an existing task on OpenSCAP Daemon""" 213 | content = request.get_json(silent=True) 214 | required_fields = {'taskTitle', 'taskTarget', 'taskSSG', 'taskTailoring', 215 | 'taskProfileId', 'taskOnlineRemediation', 'taskScheduleNotBefore', 216 | 'taskScheduleRepeatAfter'} 217 | if content is None: 218 | return '{"Error" : "json data required"}', 400 219 | elif not required_fields <= set(content): 220 | return '{"Error": "There are missing fields in the request"}', 400 221 | else: 222 | task_title = content['taskTitle'] 223 | task_target = content['taskTarget'] 224 | task_ssg = content['taskSSG'] 225 | task_tailoring = content['taskTailoring'] 226 | task_profile_id = content['taskProfileId'] 227 | task_online_remediation = content['taskOnlineRemediation'] 228 | task_schedule_not_before = content['taskScheduleNotBefore'] 229 | task_schedule_repeat_after = content['taskScheduleRepeatAfter'] 230 | task = [] 231 | try: 232 | enabled = self.system.get_task_enabled(task_id) 233 | if task_title != "": 234 | self.system.set_task_title(task_id, str(task_title)) 235 | if task_target != "": 236 | self.system.set_task_target(task_id, task_target) 237 | if task_ssg != "": 238 | self.system.set_task_input(task_id, task_ssg if task_ssg != "" else None) 239 | if task_tailoring != "": 240 | self.system.set_task_tailoring(task_id, task_tailoring if task_tailoring != "" else None) 241 | if task_profile_id != "": 242 | self.system.set_task_profile_id(task_id, task_profile_id) 243 | if task_online_remediation != "": 244 | if task_online_remediation not in [1, "y", "Y", "yes"]: 245 | task_online_remediation = False 246 | self.system.set_task_online_remediation(task_id, task_online_remediation) 247 | if task_schedule_not_before != "": 248 | try: 249 | task_schedule_not_before = datetime.strptime(task_schedule_not_before, 250 | "%Y-%m-%d %H:%M") 251 | except ValueError: 252 | task_schedule_not_before_now = datetime.now().strftime("%Y-%m-%d %H:%M") 253 | task_schedule_not_before = datetime.strptime(task_schedule_not_before_now, 254 | "%Y-%m-%d %H:%M") 255 | self.system.set_task_schedule_not_before(task_id, task_schedule_not_before) 256 | if task_schedule_repeat_after != "": 257 | schedule_repeat_after = oscap_helpers.schedule_repeat_after(task_schedule_repeat_after) 258 | self.system.set_task_schedule_repeat_after(task_id, schedule_repeat_after) 259 | task.append({'id': str(task_id), 'enabled': enabled, 'updated': 'true'}) 260 | except KeyError: 261 | return '{"Error" : "Task not found"}' 262 | update_json = '{"tasks":' + json.dumps(task, indent=4) + '}' 263 | return update_json 264 | 265 | def new_task(self): 266 | """Creates a new task on OpenSCAP Daemon""" 267 | content = request.get_json(silent=True) 268 | required_fields = {'taskTitle', 'taskTarget', 'taskSSG', 'taskTailoring', 269 | 'taskProfileId', 'taskOnlineRemediation', 'taskScheduleNotBefore', 270 | 'taskScheduleRepeatAfter'} 271 | if content is None: 272 | return '{"Error" : "json data required"}', 400 273 | elif not required_fields <= set(content): 274 | return '{"Error": "There are missing fields in the request"}', 400 275 | elif content['taskSSG'] == "" or content['taskProfileId'] == "": 276 | return '{"Error": "Both taskSSG and taskProfileId fields cannot be empty"}', 400 277 | else: 278 | task_title = content['taskTitle'] 279 | task_target = content['taskTarget'] 280 | task_ssg = content['taskSSG'] 281 | task_tailoring = content['taskTailoring'] 282 | task_profile_id = content['taskProfileId'] 283 | task_online_remediation = content['taskOnlineRemediation'] 284 | task_schedule_not_before = content['taskScheduleNotBefore'] 285 | task_schedule_repeat_after = content['taskScheduleRepeatAfter'] 286 | 287 | if task_target == "": 288 | task_target = "localhost" 289 | 290 | if task_online_remediation not in [1, "y", "Y", "yes"]: 291 | task_online_remediation = False 292 | 293 | if task_schedule_not_before == "": 294 | task_schedule_not_before_now = datetime.now().strftime("%Y-%m-%d %H:%M") 295 | task_schedule_not_before = datetime.strptime(task_schedule_not_before_now, 296 | "%Y-%m-%d %H:%M") 297 | else: 298 | try: 299 | task_schedule_not_before = datetime.strptime(task_schedule_not_before, 300 | "%Y-%m-%d %H:%M") 301 | except ValueError: 302 | return '{"Error" : "Invalid taskScheduleNotBefore format. Please use %Y-%m-%d %H:%M format"}' 303 | 304 | if task_schedule_repeat_after == "": 305 | schedule_repeat_after = 0 306 | else: 307 | schedule_repeat_after = oscap_helpers.schedule_repeat_after(task_schedule_repeat_after) 308 | 309 | task_id = self.system.create_task() 310 | self.system.set_task_title(task_id, str(task_title)) 311 | self.system.set_task_target(task_id, task_target) 312 | self.system.set_task_input(task_id, task_ssg if task_ssg != "" else None) 313 | self.system.set_task_tailoring(task_id, task_tailoring if task_tailoring != "" else None) 314 | self.system.set_task_profile_id(task_id, task_profile_id) 315 | self.system.set_task_online_remediation(task_id, task_online_remediation) 316 | self.system.set_task_schedule_not_before(task_id, task_schedule_not_before) 317 | self.system.set_task_schedule_repeat_after(task_id, schedule_repeat_after) 318 | task = [{'id': task_id, 'enabled': '0'}] 319 | create_json = '{"tasks":' + json.dumps(task, indent=4) + '}' 320 | return create_json, 201 321 | 322 | def get_task(self, task_id="all"): 323 | """Returns a list of task registered on OpenSCAP Daemon""" 324 | if task_id == "all": 325 | task_ids = self.system.list_task_ids() 326 | else: 327 | task_ids = [task_id] 328 | tasks = [] 329 | for task in task_ids: 330 | try: 331 | title = self.system.get_task_title(task) 332 | target = self.system.get_task_target(task) 333 | modified_timestamp = self.system.get_task_modified_timestamp(task) 334 | modified = datetime.fromtimestamp(modified_timestamp) 335 | enabled = self.system.get_task_enabled(task) 336 | task_results_ids = self.system.get_task_result_ids(task) 337 | task_results = [] 338 | for task_result_id in task_results_ids: 339 | exit_code = self.system.get_exit_code_of_task_result(task, task_result_id) 340 | timestamp = self.system.get_task_result_created_timestamp(task, task_result_id) 341 | # Exit code 0 means evaluation was successful and machine is compliant. 342 | # Exit code 1 means there was an error while evaluating. 343 | # Exit code 2 means there were no errors but the machine is not compliant. 344 | if exit_code == 0: 345 | status = "Compliant" 346 | elif exit_code == 1: 347 | status = "Non-Compliant" 348 | elif exit_code == 2: 349 | status = "Evaluation Error" 350 | else: 351 | status = "Unknow status for exit_code " + exit_code 352 | task_results.append({'taskResultId': str(task_result_id), 'taskResulttimestamp': timestamp, 'taskResultStatus': status}) 353 | tasks.append({'id': str(task), 'title': title, 'target': target, 'modified': str(modified), 'enabled': enabled, 'results': task_results}) 354 | except KeyError: 355 | return '{"Error" : "Task not found"}' 356 | tasks_json = '{"tasks":' + json.dumps(tasks, indent=4) + '}' 357 | return tasks_json 358 | -------------------------------------------------------------------------------- /openscap_daemon/version.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Red Hat Inc., Durham, North Carolina. 2 | # All Rights Reserved. 3 | # 4 | # openscap-daemon is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Lesser General Public License as published by 6 | # the Free Software Foundation, either version 2.1 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # openscap-daemon is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Lesser General Public License for more details. 13 | 14 | # You should have received a copy of the GNU Lesser General Public License 15 | # along with openscap-daemon. If not, see . 16 | # 17 | # Authors: 18 | # Martin Preisler 19 | 20 | VERSION_MAJOR = 0 21 | VERSION_MINOR = 1 22 | VERSION_PATCH = 11 23 | 24 | VERSION_STRING = "%i.%i.%i" % (VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH) 25 | 26 | __all__ = ["VERSION_MAJOR", "VERSION_MINOR", "VERSION_PATCH", "VERSION_STRING"] 27 | -------------------------------------------------------------------------------- /org.oscapd.conf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /oscapd.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=OpenSCAP Daemon 3 | Documentation=http://open-scap.org/tools/openscap-daemon 4 | 5 | [Service] 6 | Type=dbus 7 | BusName=org.OpenSCAP.daemon 8 | ExecStart=/usr/bin/oscapd 9 | 10 | [Install] 11 | WantedBy=multi-user.target 12 | -------------------------------------------------------------------------------- /perform-static-analysis: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | OUTPUT_FILE="static-analysis-output" 4 | 5 | which pylint && which pychecker && which pyflakes 6 | if [ $? -ne 0 ]; then 7 | echo "One or more dependencies were not found!" 8 | exit 1 9 | fi 10 | 11 | echo "Output from static analysis tools" > $OUTPUT_FILE 12 | echo "=================================" >> $OUTPUT_FILE 13 | echo >> $OUTPUT_FILE 14 | 15 | echo "Running pylint 1/3..." 16 | echo "pylint:" >> $OUTPUT_FILE 17 | echo >> $OUTPUT_FILE 18 | pylint --rcfile pylint.cfg openscap_daemon >> $OUTPUT_FILE 19 | echo >> $OUTPUT_FILE 20 | 21 | echo "Running pychecker 2/3..." 22 | echo "pychecker:" >> $OUTPUT_FILE 23 | echo >> $OUTPUT_FILE 24 | find openscap_daemon/ -name "*\.py" -print0 | xargs --null pychecker --limit 0 2>&1 >> $OUTPUT_FILE 25 | echo >> $OUTPUT_FILE 26 | 27 | echo "Running pyflakes 3/3..." 28 | echo "pyflakes:" >> $OUTPUT_FILE 29 | echo >> $OUTPUT_FILE 30 | pyflakes openscap_daemon >> $OUTPUT_FILE 31 | echo >> $OUTPUT_FILE 32 | 33 | echo "Static analysis finished, output in $OUTPUT_FILE" 34 | -------------------------------------------------------------------------------- /pylint.cfg: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # Specify a configuration file. 4 | #rcfile= 5 | 6 | # Python code to execute, usually for sys.path manipulation such as 7 | # pygtk.require(). 8 | #init-hook= 9 | 10 | # Profiled execution. 11 | profile=no 12 | 13 | # Add files or directories to the blacklist. They should be base names, not 14 | # paths. 15 | ignore=.git 16 | 17 | # Pickle collected data for later comparisons. 18 | persistent=yes 19 | 20 | # List of plugins (as comma separated values of python modules names) to load, 21 | # usually to register additional checkers. 22 | load-plugins= 23 | 24 | 25 | [MESSAGES CONTROL] 26 | 27 | # Enable the message, report, category or checker with the given id(s). You can 28 | # either give multiple identifier separated by comma (,) or put this option 29 | # multiple time. 30 | #enable= 31 | 32 | # Disable the message, report, category or checker with the given id(s). You 33 | # can either give multiple identifier separated by comma (,) or put this option 34 | # multiple time (only on the command line, not in the configuration file where 35 | # it should appear only once). 36 | 37 | # line too long, no docstring, too many members 38 | disable=C0301, C0111, R0904 39 | 40 | [REPORTS] 41 | 42 | # Set the output format. Available formats are text, parseable, colorized, msvs 43 | # (visual studio) and html 44 | output-format=text 45 | 46 | # Include message's id in output 47 | include-ids=no 48 | 49 | # Put messages in a separate file for each module / package specified on the 50 | # command line instead of printing them on stdout. Reports (if any) will be 51 | # written in a file name "pylint_global.[txt|html]". 52 | files-output=no 53 | 54 | # Tells whether to display a full report or only the messages 55 | reports=yes 56 | 57 | # Python expression which should return a note less than 10 (10 is the highest 58 | # note). You have access to the variables errors warning, statement which 59 | # respectively contain the number of errors / warnings messages and the total 60 | # number of statements analyzed. This is used by the global evaluation report 61 | # (RP0004). 62 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 63 | 64 | # Add a comment according to your evaluation note. This is used by the global 65 | # evaluation report (RP0004). 66 | comment=no 67 | 68 | 69 | [TYPECHECK] 70 | 71 | # Tells whether missing members accessed in mixin class should be ignored. A 72 | # mixin class is detected if its name ends with "mixin" (case insensitive). 73 | ignore-mixin-members=yes 74 | 75 | # List of classes names for which member attributes should not be checked 76 | # (useful for classes with attributes dynamically set). 77 | ignored-classes= 78 | 79 | # When zope mode is activated, add a predefined set of Zope acquired attributes 80 | # to generated-members. 81 | zope=no 82 | 83 | # List of members which are set dynamically and missed by pylint inference 84 | # system, and so shouldn't trigger E0201 when accessed. Python regular 85 | # expressions are accepted. 86 | generated-members=REQUEST,acl_users,aq_parent 87 | 88 | 89 | [BASIC] 90 | 91 | # Required attributes for module, separated by a comma 92 | required-attributes= 93 | 94 | # List of builtins function names that should not be used, separated by a comma 95 | bad-functions=map,filter,apply,input 96 | 97 | # Good variable names which should always be accepted, separated by a comma 98 | good-names=i,j,k,ex,_ 99 | 100 | # Bad variable names which should always be refused, separated by a comma 101 | bad-names=foo,bar,baz,toto,tutu,tata 102 | 103 | # Regular expression which should only match functions or classes name which do 104 | # not require a docstring 105 | no-docstring-rgx=__.*__ 106 | 107 | 108 | [SIMILARITIES] 109 | 110 | # Minimum lines number of a similarity. 111 | min-similarity-lines=4 112 | 113 | # Ignore comments when computing similarities. 114 | ignore-comments=yes 115 | 116 | # Ignore docstrings when computing similarities. 117 | ignore-docstrings=yes 118 | 119 | 120 | [FORMAT] 121 | 122 | # Maximum number of characters on a single line. 123 | max-line-length=80 124 | 125 | # Maximum number of lines in a module 126 | max-module-lines=1000 127 | 128 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 129 | # tab). 130 | indent-string=' ' 131 | 132 | 133 | [VARIABLES] 134 | 135 | # Tells whether we should check for unused import in __init__ files. 136 | init-import=no 137 | 138 | # A regular expression matching the beginning of the name of dummy variables 139 | # (i.e. not used). 140 | dummy-variables-rgx=_|dummy 141 | 142 | # List of additional names supposed to be defined in builtins. Remember that 143 | # you should avoid to define new builtins when possible. 144 | additional-builtins= 145 | 146 | 147 | [MISCELLANEOUS] 148 | 149 | # List of note tags to take in consideration, separated by a comma. 150 | notes=FIXME,XXX,TODO 151 | 152 | 153 | [CLASSES] 154 | 155 | # List of interface methods to ignore, separated by a comma. This is used for 156 | # instance to not check methods defines in Zope's Interface base class. 157 | ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by 158 | 159 | # List of method names used to declare (i.e. assign) instance attributes. 160 | defining-attr-methods=__init__,__new__,setUp 161 | 162 | 163 | [IMPORTS] 164 | 165 | # Deprecated modules which should not be used, separated by a comma 166 | deprecated-modules=regsub,string,TERMIOS,Bastion,rexec 167 | 168 | # Create a graph of every (i.e. internal and external) dependencies in the 169 | # given file (report RP0402 must not be disabled) 170 | import-graph= 171 | 172 | # Create a graph of external dependencies in the given file (report RP0402 must 173 | # not be disabled) 174 | ext-import-graph= 175 | 176 | # Create a graph of internal dependencies in the given file (report RP0402 must 177 | # not be disabled) 178 | int-import-graph= 179 | 180 | 181 | [DESIGN] 182 | 183 | # Maximum number of arguments for function / method 184 | max-args=5 185 | 186 | # Argument names that match this expression will be ignored. Default to name 187 | # with leading underscore 188 | ignored-argument-names=_.* 189 | 190 | # Maximum number of locals for function / method body 191 | max-locals=15 192 | 193 | # Maximum number of return / yield for function / method body 194 | max-returns=6 195 | 196 | # Maximum number of branch for function / method body 197 | max-branchs=12 198 | 199 | # Maximum number of statements in function / method body 200 | max-statements=50 201 | 202 | # Maximum number of parents for a class (see R0901). 203 | max-parents=7 204 | 205 | # Maximum number of attributes for a class (see R0902). 206 | max-attributes=20 207 | 208 | # Minimum number of public methods for a class (see R0903). 209 | min-public-methods=2 210 | 211 | # Maximum number of public methods for a class (see R0904). 212 | max-public-methods=30 213 | -------------------------------------------------------------------------------- /runwrapper.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright 2015 Red Hat Inc., Durham, North Carolina. 4 | # All Rights Reserved. 5 | # 6 | # openscap-daemon is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Lesser General Public License as published by 8 | # the Free Software Foundation, either version 2.1 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # openscap-daemon is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Lesser General Public License for more details. 15 | 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with openscap-daemon. If not, see . 18 | # 19 | # Authors: 20 | # Martin Preisler 21 | 22 | # parent dir of this script 23 | PARENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 24 | 25 | # add directory with "openscap_daemon" to $PYTHONPATH 26 | export PYTHONPATH=$PARENT_DIR:$PYTHONPATH 27 | # force python to print using utf-8 28 | export PYTHONIOENCODING=UTF-8 29 | 30 | export OSCAPD_CONFIG_FILE="$PARENT_DIR/tests/data_dir_template/config.ini" 31 | export OSCAPD_SESSION_BUS="1" 32 | 33 | if [ "x$RUNWRAPPER_NO_FORK" != "x1" ]; then 34 | # fork a new shell to avoid polluting the environment 35 | bash 36 | fi 37 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Copyright 2015 Red Hat Inc., Durham, North Carolina. 4 | # All Rights Reserved. 5 | # 6 | # openscap-daemon is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Lesser General Public License as published by 8 | # the Free Software Foundation, either version 2.1 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # openscap-daemon is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Lesser General Public License for more details. 15 | 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with openscap-daemon. If not, see . 18 | # 19 | # Authors: 20 | # Martin Preisler 21 | 22 | import os 23 | import os.path 24 | 25 | from openscap_daemon import version 26 | 27 | from distutils.core import setup 28 | 29 | 30 | def get_packages(): 31 | # Distutils requires us to list all packages, this is very tedious and prone 32 | # to errors. This method crawls the hierarchy and gathers all packages. 33 | 34 | ret = ["openscap_daemon"] 35 | 36 | for dirpath, _, files in os.walk("openscap_daemon"): 37 | if "__init__.py" in files: 38 | ret.append(dirpath.replace(os.path.sep, ".")) 39 | 40 | return ret 41 | 42 | 43 | setup( 44 | name="openscap_daemon", 45 | version=version.VERSION_STRING, 46 | author="Martin Preisler, Brent Baude and others", 47 | author_email="mpreisle@redhat.com", 48 | description="...", 49 | license="LGPL2.1+", 50 | url="http://www.open-scap.org/", 51 | packages=get_packages(), 52 | scripts=[ 53 | os.path.join("bin", "oscapd"), 54 | os.path.join("bin", "oscapd-cli"), 55 | os.path.join("bin", "oscapd-evaluate") 56 | ], 57 | data_files=[ 58 | (os.path.join("/", "etc", "dbus-1", "system.d"), 59 | ["org.oscapd.conf"]), 60 | (os.path.join("/", "usr", "lib", "systemd", "system"), 61 | ["oscapd.service"]), 62 | (os.path.join("/", "usr", "share", "doc", "openscap-daemon"), 63 | ["README.md", "LICENSE"]), 64 | (os.path.join("/", "usr", "share", "man", "man8"), 65 | ["man/oscapd.8", "man/oscapd-cli.8", "man/oscapd-evaluate.8"]), 66 | ], 67 | ) 68 | -------------------------------------------------------------------------------- /tests/data_dir_template/config.ini: -------------------------------------------------------------------------------- 1 | [General] 2 | tasks-dir=./tasks 3 | results-dir=./results 4 | work-in-progress-dir=./work_in_progress 5 | cve-feeds-dir=./cve_feeds 6 | jobs=4 7 | max-results-to-keep=100 8 | 9 | [Tools] 10 | oscap= 11 | oscap-ssh= 12 | oscap-vm= 13 | oscap-docker= 14 | 15 | [Content] 16 | ssg= 17 | 18 | [CVEScanner] 19 | fetch-cve-url= 20 | 21 | [REST] 22 | enabled= 23 | 24 | -------------------------------------------------------------------------------- /tests/data_dir_template/config_test.ini: -------------------------------------------------------------------------------- 1 | [General] 2 | jobs=8 3 | 4 | [Tools] 5 | oscap=/a/b/c/oscap 6 | oscap-ssh=/d/e/f/oscap-ssh 7 | oscap-vm=/openscap/bin/oscap-vm 8 | oscap-docker=/g/h/i/j/oscap-docker 9 | 10 | [Content] 11 | ssg=/g/h/i/ssg/content 12 | 13 | [CVEScanner] 14 | fetch-cve-url=http://a.b.com/some/folder/ 15 | -------------------------------------------------------------------------------- /tests/install_test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright 2016 Red Hat Inc., Durham, North Carolina. 4 | # All Rights Reserved. 5 | # 6 | # openscap-daemon is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Lesser General Public License as published by 8 | # the Free Software Foundation, either version 2.1 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # openscap-daemon is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Lesser General Public License for more details. 15 | 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with openscap-daemon. If not, see . 18 | # 19 | # Authors: 20 | # Martin Preisler 21 | 22 | echo "Running install tests..." 23 | echo 24 | 25 | PARENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 26 | 27 | EXIT_CODE=0 28 | 29 | test="setup.py --dry-run" 30 | printf "%-60s %s ... " "$test" 31 | 32 | pushd $PARENT_DIR/.. > /dev/null 33 | output=`$PYTHON setup.py --dry-run install 2>&1` 34 | if [ "$?" == "0" ]; then 35 | echo "[ pass ]" 36 | else 37 | echo "[ FAIL ]" 38 | echo 39 | echo "$output" 40 | echo 41 | EXIT_CODE=1 42 | fi 43 | popd > /dev/null 44 | 45 | exit $EXIT_CODE 46 | -------------------------------------------------------------------------------- /tests/integration/make_check: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright 2016 Red Hat Inc., Durham, North Carolina. 4 | # All Rights Reserved. 5 | # 6 | # openscap-daemon is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Lesser General Public License as published by 8 | # the Free Software Foundation, either version 2.1 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # openscap-daemon is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Lesser General Public License for more details. 15 | 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with openscap-daemon. If not, see . 18 | # 19 | # Authors: 20 | # Martin Preisler 21 | 22 | echo "Running integration tests..." 23 | echo 24 | 25 | # parent dir of this script 26 | PARENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 27 | pushd $PARENT_DIR > /dev/null 28 | 29 | BIN="$PARENT_DIR/../../bin" 30 | export BIN 31 | DATA_DIR_TEMPLATE="$PARENT_DIR/../../tests/data_dir_template" 32 | export DATA_DIR_TEMPLATE 33 | 34 | RUNWRAPPER_NO_FORK=1 source ../../runwrapper.sh 35 | 36 | EXIT_CODE=0 37 | for file in test_*.sh 38 | do 39 | printf "%-60s %s ... " "$file" 40 | output=`./$file 2>&1` 41 | if [ "$?" == "0" ]; then 42 | echo "[ pass ]" 43 | else 44 | echo "[ FAIL ]" 45 | echo 46 | echo "$output" 47 | echo 48 | EXIT_CODE=1 49 | fi 50 | done 51 | 52 | popd > /dev/null 53 | 54 | exit $EXIT_CODE 55 | -------------------------------------------------------------------------------- /tests/integration/test_oscapd_cli_standalone.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # calls in this test should not require oscapd to be running 4 | 5 | $PYTHON $BIN/oscapd-cli 6 | [ $? -eq 2 ] || exit 1 7 | $PYTHON $BIN/oscapd-cli --version || exit 1 8 | $PYTHON $BIN/oscapd-cli -v || exit 1 9 | $PYTHON $BIN/oscapd-cli --help || exit 1 10 | $PYTHON $BIN/oscapd-cli -h || exit 1 11 | -------------------------------------------------------------------------------- /tests/integration/test_oscapd_evaluate_standalone.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # calls in this test should not require oscapd to be running 4 | 5 | $PYTHON $BIN/oscapd-evaluate --help || exit 1 6 | $PYTHON $BIN/oscapd-evaluate --h || exit 1 7 | $PYTHON $BIN/oscapd-evaluate -v || exit 1 8 | $PYTHON $BIN/oscapd-evaluate config || exit 1 9 | $PYTHON $BIN/oscapd-evaluate --verbose config || exit 1 10 | $PYTHON $BIN/oscapd-evaluate spec --input ../testing_data/ssg-fedora-ds.xml --print-xml || exit 1 11 | $PYTHON $BIN/oscapd-evaluate spec --input ../testing_data/ssg-fedora-ds.xml --profile xccdf_org.ssgproject.content_profile_common --print-xml || exit 1 12 | -------------------------------------------------------------------------------- /tests/integration/test_task_management.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | # TODO: Disable this test for now, it fails on Jenkins because Xorg is not there 6 | exit 0 7 | 8 | TMPDIR=$(mktemp -d) 9 | cp -r "$DATA_DIR_TEMPLATE" "$TMPDIR" 10 | 11 | export OSCAPD_CONFIG_FILE="$TMPDIR/data_dir_template/config.ini" 12 | export OSCAPD_SESSION_BUS="1" 13 | 14 | $PYTHON $BIN/oscapd & 15 | OSCAPD_PID=$! 16 | 17 | sleep 2 18 | 19 | $PYTHON $BIN/oscapd-cli task 20 | 21 | kill $OSCAPD_PID 22 | 23 | rm -rf "$TMPDIR" 24 | -------------------------------------------------------------------------------- /tests/make_check: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright 2016 Red Hat Inc., Durham, North Carolina. 4 | # All Rights Reserved. 5 | # 6 | # openscap-daemon is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Lesser General Public License as published by 8 | # the Free Software Foundation, either version 2.1 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # openscap-daemon is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Lesser General Public License for more details. 15 | 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with openscap-daemon. If not, see . 18 | # 19 | # Authors: 20 | # Martin Preisler 21 | 22 | # parent dir of this script 23 | PARENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 24 | pushd $PARENT_DIR > /dev/null 25 | 26 | PYTHON_VERSIONS="$(which python2 2> /dev/null) $(which python3 2> /dev/null)" 27 | 28 | for PYTHON in $PYTHON_VERSIONS; 29 | do 30 | echo "Testing with python '$PYTHON'" 31 | echo 32 | export PYTHON 33 | 34 | ./unit/make_check || exit 1 35 | echo 36 | ./integration/make_check || exit 1 37 | echo 38 | ./install_test || exit 1 39 | echo 40 | 41 | echo 42 | done 43 | 44 | popd > /dev/null 45 | -------------------------------------------------------------------------------- /tests/testing_data/evaluation_spec_cve_scan.xml: -------------------------------------------------------------------------------- 1 | cve_scanlocalhostfalse 2 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenSCAP/openscap-daemon/70eec2cb399aea04be8c7a9bc215eb855bfb221e/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/make_check: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright 2015 Red Hat Inc., Durham, North Carolina. 4 | # All Rights Reserved. 5 | # 6 | # openscap-daemon is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Lesser General Public License as published by 8 | # the Free Software Foundation, either version 2.1 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # openscap-daemon is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Lesser General Public License for more details. 15 | 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with openscap-daemon. If not, see . 18 | # 19 | # Authors: 20 | # Martin Preisler 21 | 22 | echo "Running unit tests..." 23 | echo 24 | 25 | # parent dir of this script 26 | PARENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 27 | pushd $PARENT_DIR > /dev/null 28 | 29 | # add directory with "unit" to $PYTHONPATH 30 | export PYTHONPATH=$PARENT_DIR/tests/unit:$PYTHONPATH 31 | 32 | RUNWRAPPER_NO_FORK=1 source ../../runwrapper.sh 33 | 34 | EXIT_CODE=0 35 | for file in test_*.py 36 | do 37 | printf "%-60s %s ... " "$file" 38 | output=`$PYTHON ./$file 2>&1` 39 | if [ "$?" == "0" ]; then 40 | echo "[ pass ]" 41 | else 42 | echo "[ FAIL ]" 43 | echo 44 | echo "$output" 45 | echo 46 | EXIT_CODE=1 47 | fi 48 | done 49 | 50 | popd > /dev/null 51 | 52 | exit $EXIT_CODE 53 | -------------------------------------------------------------------------------- /tests/unit/test_basic_update.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python2 2 | 3 | # Copyright 2015 Red Hat Inc., Durham, North Carolina. 4 | # All Rights Reserved. 5 | # 6 | # openscap-daemon is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Lesser General Public License as published by 8 | # the Free Software Foundation, either version 2.1 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # openscap-daemon is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Lesser General Public License for more details. 15 | 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with openscap-daemon. If not, see . 18 | # 19 | # Authors: 20 | # Martin Preisler 21 | 22 | import unit_test_harness 23 | import time 24 | 25 | 26 | class BasicUpdateTest(unit_test_harness.APITest): 27 | def setup_data(self): 28 | super(BasicUpdateTest, self).setup_data() 29 | self.copy_to_data("tasks/1.xml") 30 | 31 | def test(self): 32 | super(BasicUpdateTest, self).test() 33 | 34 | self.system.load_tasks() 35 | assert(len(self.system.tasks) == 1) 36 | 37 | print(self.system.tasks) 38 | self.system.schedule_tasks() 39 | 40 | while len(self.system.async_manager.actions) > 0: 41 | time.sleep(1) 42 | 43 | 44 | if __name__ == "__main__": 45 | BasicUpdateTest.run() 46 | -------------------------------------------------------------------------------- /tests/unit/test_config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python2 2 | 3 | # Copyright 2015 Red Hat Inc., Durham, North Carolina. 4 | # All Rights Reserved. 5 | # 6 | # openscap-daemon is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Lesser General Public License as published by 8 | # the Free Software Foundation, either version 2.1 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # openscap-daemon is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Lesser General Public License for more details. 15 | 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with openscap-daemon. If not, see . 18 | # 19 | # Authors: 20 | # Martin Preisler 21 | 22 | import unit_test_harness 23 | import os.path 24 | import openscap_daemon.config 25 | 26 | 27 | # TODO: The harness initializes System and we don't need that here 28 | class ConfigTest(unit_test_harness.APITest): 29 | def setup_data(self): 30 | super(ConfigTest, self).setup_data() 31 | 32 | self.copy_to_data("config_test.ini") 33 | 34 | def test(self): 35 | super(ConfigTest, self).test() 36 | 37 | config = openscap_daemon.config.Configuration() 38 | full_path = os.path.abspath( 39 | os.path.join(self.data_dir_path, "config_test.ini") 40 | ) 41 | config.load(full_path) 42 | assert(config.config_file == full_path) 43 | 44 | assert(config.jobs == 8) 45 | 46 | assert(config.oscap_path == "/a/b/c/oscap") 47 | assert(config.oscap_ssh_path == "/d/e/f/oscap-ssh") 48 | assert(config.oscap_vm_path == "/openscap/bin/oscap-vm") 49 | assert(config.oscap_docker_path == "/g/h/i/j/oscap-docker") 50 | 51 | assert(config.ssg_path == "/g/h/i/ssg/content") 52 | 53 | assert(config.fetch_cve_url == "http://a.b.com/some/folder/") 54 | 55 | saved_full_path = os.path.join(self.data_dir_path, "config_test_s.ini") 56 | config.save_as(saved_full_path) 57 | assert(config.config_file == saved_full_path) 58 | 59 | config2 = openscap_daemon.config.Configuration() 60 | config2.load(saved_full_path) 61 | assert(config2.config_file == saved_full_path) 62 | 63 | assert(config2.jobs == 8) 64 | 65 | assert(config2.oscap_path == "/a/b/c/oscap") 66 | assert(config2.oscap_ssh_path == "/d/e/f/oscap-ssh") 67 | assert(config2.oscap_vm_path == "/openscap/bin/oscap-vm") 68 | assert(config2.oscap_docker_path == "/g/h/i/j/oscap-docker") 69 | 70 | assert(config2.ssg_path == "/g/h/i/ssg/content") 71 | 72 | assert(config2.fetch_cve_url == "http://a.b.com/some/folder/") 73 | 74 | 75 | if __name__ == "__main__": 76 | ConfigTest.run() 77 | -------------------------------------------------------------------------------- /tests/unit/test_generate_guide.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python2 2 | 3 | # Copyright 2015 Red Hat Inc., Durham, North Carolina. 4 | # All Rights Reserved. 5 | # 6 | # openscap-daemon is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Lesser General Public License as published by 8 | # the Free Software Foundation, either version 2.1 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # openscap-daemon is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Lesser General Public License for more details. 15 | 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with openscap-daemon. If not, see . 18 | # 19 | # Authors: 20 | # Martin Preisler 21 | 22 | import unit_test_harness 23 | 24 | 25 | class GenerateGuideTest(unit_test_harness.APITest): 26 | def setup_data(self): 27 | super(GenerateGuideTest, self).setup_data() 28 | self.copy_to_data("tasks/1.xml") 29 | 30 | def test(self): 31 | super(GenerateGuideTest, self).test() 32 | 33 | self.system.load_tasks() 34 | assert(len(self.system.tasks) == 1) 35 | 36 | print(self.system.generate_guide_for_task(1)) 37 | 38 | 39 | if __name__ == "__main__": 40 | GenerateGuideTest.run() 41 | -------------------------------------------------------------------------------- /tests/unit/test_generate_report.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python2 2 | 3 | # Copyright 2015 Red Hat Inc., Durham, North Carolina. 4 | # All Rights Reserved. 5 | # 6 | # openscap-daemon is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Lesser General Public License as published by 8 | # the Free Software Foundation, either version 2.1 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # openscap-daemon is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Lesser General Public License for more details. 15 | 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with openscap-daemon. If not, see . 18 | # 19 | # Authors: 20 | # Martin Preisler 21 | 22 | import unit_test_harness 23 | 24 | 25 | class GenerateReportTest(unit_test_harness.APITest): 26 | def setup_data(self): 27 | super(GenerateReportTest, self).setup_data() 28 | self.copy_to_data("tasks/1.xml") 29 | #self.ensure_dir("results/1/1") 30 | #self.copy_to_data("results/1/1") 31 | 32 | def test(self): 33 | super(GenerateReportTest, self).test() 34 | 35 | self.system.load_tasks() 36 | assert(len(self.system.tasks) == 1) 37 | 38 | #print(self.system.generate_report_for_task_result(1, 1)) 39 | 40 | 41 | if __name__ == "__main__": 42 | GenerateReportTest.run() 43 | -------------------------------------------------------------------------------- /tests/unit/test_serialization.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python2 2 | 3 | # Copyright 2015 Red Hat Inc., Durham, North Carolina. 4 | # All Rights Reserved. 5 | # 6 | # openscap-daemon is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Lesser General Public License as published by 8 | # the Free Software Foundation, either version 2.1 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # openscap-daemon is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Lesser General Public License for more details. 15 | 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with openscap-daemon. If not, see . 18 | # 19 | # Authors: 20 | # Martin Preisler 21 | 22 | import unit_test_harness 23 | import os.path 24 | 25 | 26 | class SerializationTest(unit_test_harness.APITest): 27 | def setup_data(self): 28 | super(SerializationTest, self).setup_data() 29 | self.copy_to_data("tasks/1.xml") 30 | 31 | def test(self): 32 | super(SerializationTest, self).test() 33 | 34 | self.system.load_tasks() 35 | assert(len(self.system.tasks) == 1) 36 | self.system.tasks[1].save_as( 37 | os.path.join(self.data_dir_path, "tasks", "2.xml") 38 | ) 39 | self.system.load_tasks() 40 | assert(len(self.system.tasks) == 2) 41 | 42 | assert( 43 | self.system.tasks[1].is_equivalent_to(self.system.tasks[2]) 44 | ) 45 | self.system.tasks[2].title = "Broken!" 46 | assert( 47 | not self.system.tasks[1].is_equivalent_to(self.system.tasks[2]) 48 | ) 49 | 50 | task_id = self.system.create_task() 51 | self.system.tasks[task_id].save() 52 | 53 | 54 | if __name__ == "__main__": 55 | SerializationTest.run() 56 | -------------------------------------------------------------------------------- /tests/unit/unit_test_harness.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python2 2 | 3 | # Copyright 2015 Red Hat Inc., Durham, North Carolina. 4 | # All Rights Reserved. 5 | # 6 | # openscap-daemon is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Lesser General Public License as published by 8 | # the Free Software Foundation, either version 2.1 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # openscap-daemon is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Lesser General Public License for more details. 15 | 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with openscap-daemon. If not, see . 18 | # 19 | # Authors: 20 | # Martin Preisler 21 | 22 | import openscap_daemon 23 | from openscap_daemon.config import Configuration 24 | 25 | import tempfile 26 | import shutil 27 | import os.path 28 | 29 | 30 | def get_template_data_dir(): 31 | # Beware, nasty tricks ahead! 32 | return os.path.join( 33 | os.path.dirname(os.path.dirname(__file__)), 34 | "data_dir_template" 35 | ) 36 | 37 | 38 | class APITest(object): 39 | """Needs a data_dir to work 40 | """ 41 | 42 | def __init__(self, data_dir_path): 43 | self.system = None 44 | self.data_dir_path = data_dir_path 45 | 46 | def copy_to_data(self, template_path): 47 | """Overrides of setup_data are supposed to use this to copy special 48 | data files into the temporary data directory. 49 | """ 50 | 51 | shutil.copy( 52 | os.path.join(get_template_data_dir(), template_path), 53 | os.path.join(self.data_dir_path, template_path) 54 | ) 55 | 56 | def setup_data(self): 57 | # This ensures that data_dir is prepared and all the directories are in 58 | # their place. This is necessary so that we can later copy in our test 59 | # files. 60 | 61 | assert(os.path.isdir(self.data_dir_path)) 62 | self.copy_to_data("config.ini") 63 | 64 | # we do this to create all the necessary directories 65 | fake_config = Configuration() 66 | fake_config.load(os.path.join(self.data_dir_path, "config.ini")) 67 | fake_config.prepare_dirs() 68 | 69 | def init_system(self): 70 | self.system = openscap_daemon.System( 71 | os.path.join(self.data_dir_path, "config.ini") 72 | ) 73 | 74 | def teardown_data(self): 75 | # Most implementations won't do anything here, the entire directory will 76 | # be recursively removed anyway. 77 | pass 78 | 79 | def test(self): 80 | # This is the important method, this is where code is run 81 | pass 82 | 83 | @classmethod 84 | def run(cls): 85 | temp_dir = None 86 | 87 | try: 88 | temp_dir = tempfile.mkdtemp() 89 | instance = cls(temp_dir) 90 | instance.setup_data() 91 | instance.init_system() 92 | instance.test() 93 | instance.teardown_data() 94 | 95 | shutil.rmtree(temp_dir) 96 | 97 | except: 98 | if temp_dir is not None: 99 | print( 100 | "Examine '%s' to debug failure of this test.\n" % (temp_dir) 101 | ) 102 | 103 | raise 104 | --------------------------------------------------------------------------------