├── src ├── __init__.py ├── ukip.py └── ukip_test.py ├── requirements.txt ├── data ├── ukip.service ├── allowlist └── keycodes ├── CONTRIBUTING.md ├── setup.sh ├── README.md └── LICENSE /src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | attrs==18.1.0 2 | pyudev==0.21.0 3 | attr==0.3.1 4 | evdev==1.3.0 5 | pyusb==1.0.2 6 | -------------------------------------------------------------------------------- /data/ukip.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=UKIP 3 | Requires=systemd-udevd.service 4 | After=systemd-udevd.service 5 | 6 | [Service] 7 | ExecStart=/usr/sbin/ukip 8 | 9 | [Install] 10 | WantedBy=multi-user.target 11 | -------------------------------------------------------------------------------- /data/allowlist: -------------------------------------------------------------------------------- 1 | # This is the allowlist for UKIP: USB Keystroke Injection Protection. 2 | # 3 | # Devices are added manually by a user in the following form, one rule per line: 4 | # : 5 | # An example for the Yubikey: 6 | 0x10:0x1050 c,b,d,e,f,g,h,i,j,k,l,n,r,t,u,v 7 | 8 | # If every character should be allowed, the product ID and vendor ID, followed 9 | # by the keyword any is sufficient. 10 | # 11 | # The following would be an example for the product ID 0x1234 and the vendor ID 12 | # 0x1337 (without the starting hashtag): 13 | # 0x1234:0x1337 any 14 | 15 | # If no character should be allowed, the approach is similar, but the keyword is 16 | # none. 17 | # 18 | # The following would be an example for the product ID 0x1337 and the vendor ID 19 | # 0x1234 (without the starting hashtag): 20 | # 0x1337:0x1234 none 21 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement. You (or your employer) retain the copyright to your contribution; 10 | this simply gives us permission to use and redistribute your contributions as 11 | part of the project. Head over to to see 12 | your current agreements on file or to sign a new one. 13 | 14 | You generally only need to submit a CLA once, so if you've already submitted one 15 | (even if it was for a different project), you probably don't need to do it 16 | again. 17 | 18 | ## Code reviews 19 | 20 | All submissions, including submissions by project members, require review. We 21 | use GitHub pull requests for this purpose. Consult 22 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 23 | information on using pull requests. 24 | 25 | ## Community Guidelines 26 | 27 | This project follows 28 | [Google's Open Source Community Guidelines](https://opensource.google/conduct/). 29 | -------------------------------------------------------------------------------- /data/keycodes: -------------------------------------------------------------------------------- 1 | { 2 | "lowcodes": 3 | [{ 4 | "1": "ESC", 5 | "2": "1", 6 | "3": "2", 7 | "4": "3", 8 | "5": "4", 9 | "6": "5", 10 | "7": "6", 11 | "8": "7", 12 | "9": "8", 13 | "10": "9", 14 | "11": "0", 15 | "12": "-", 16 | "13": "=", 17 | "14": "BKSP", 18 | "15": "TAB", 19 | "16": "q", 20 | "17": "w", 21 | "18": "e", 22 | "19": "r", 23 | "20": "t", 24 | "21": "y", 25 | "22": "u", 26 | "23": "i", 27 | "24": "o", 28 | "25": "p", 29 | "26": "[", 30 | "27": "]", 31 | "28": "CRLF", 32 | "29": "LCTRL", 33 | "30": "a", 34 | "31": "s", 35 | "32": "d", 36 | "33": "f", 37 | "34": "g", 38 | "35": "h", 39 | "36": "j", 40 | "37": "k", 41 | "38": "l", 42 | "39": ";", 43 | "40": "\"", 44 | "41": "`", 45 | "42": "LSHFT", 46 | "43": "\\", 47 | "44": "z", 48 | "45": "x", 49 | "46": "c", 50 | "47": "v", 51 | "48": "b", 52 | "49": "n", 53 | "50": "m", 54 | "51": ",", 55 | "52": ".", 56 | "53": "/", 57 | "54": "RSHFT", 58 | "56": "LALT", 59 | "57": " ", 60 | "100": "RALT" 61 | }], 62 | 63 | "capscodes": 64 | [{ 65 | "1": "ESC", 66 | "2": "!", 67 | "3": "@", 68 | "4": "#", 69 | "5": "$", 70 | "6": "%", 71 | "7": "^", 72 | "8": "&", 73 | "9": "*", 74 | "10": "(", 75 | "11": ")", 76 | "12": "_", 77 | "13": "+", 78 | "14": "BKSP", 79 | "15": "TAB", 80 | "16": "Q", 81 | "17": "W", 82 | "18": "E", 83 | "19": "R", 84 | "20": "T", 85 | "21": "Y", 86 | "22": "U", 87 | "23": "I", 88 | "24": "O", 89 | "25": "P", 90 | "26": "{", 91 | "27": "}", 92 | "28": "CRLF", 93 | "29": "LCTRL", 94 | "30": "A", 95 | "31": "S", 96 | "32": "D", 97 | "33": "F", 98 | "34": "G", 99 | "35": "H", 100 | "36": "J", 101 | "37": "K", 102 | "38": "L", 103 | "39": ":", 104 | "40": "'", 105 | "41": "~", 106 | "42": "LSHFT", 107 | "43": "|", 108 | "44": "Z", 109 | "45": "X", 110 | "46": "C", 111 | "47": "V", 112 | "48": "B", 113 | "49": "N", 114 | "50": "M", 115 | "51": "<", 116 | "52": ">", 117 | "53": "?", 118 | "54": "RSHFT", 119 | "56": "LALT", 120 | "57": " ", 121 | "100": "RALT" 122 | }] 123 | } 124 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright 2019 Google LLC 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | # Replace those variables to fit your needs. 17 | NEW_KEYSTROKE_WINDOW=5 18 | NEW_ABNORMAL_TYPING=50000 19 | # Set either MONITOR or HARDENING. 20 | RUN_MODE=MONITOR 21 | 22 | # For systemd it's important to know which Linux flavor. 23 | DEBIAN=true 24 | 25 | # Path to virtual environment (.ukip/ in the user's home). 26 | VENV_PATH=$HOME'/.ukip/' 27 | 28 | 29 | function info() { 30 | echo -e "[\e[94m*\e[0m]" "$@" 31 | } 32 | 33 | function error() { 34 | echo -e "[\e[91m!\e[0m]" "$@" 35 | } 36 | 37 | function success() { 38 | echo -e "[\e[92m+\e[0m]" "$@" 39 | } 40 | 41 | function fatal() { 42 | error "$@" 43 | exit 1 44 | } 45 | 46 | function install_virtual_env() { 47 | # Replace the shebang line. 48 | sed -i 's@#!/usr/bin/env python3@#!'$VENV_PATH'bin/python3@g' src/ukip.py 49 | 50 | # Install the needed virtual environemt. 51 | /usr/bin/env python3 -m venv $VENV_PATH 52 | 53 | # Activate the venv. 54 | source $VENV_PATH'bin/activate' 55 | 56 | # Install wheel before requirements. 57 | /usr/bin/env pip3 -q install wheel 58 | 59 | # Install the required packages. 60 | /usr/bin/env pip3 -q install -r requirements.txt 61 | 62 | success "Successfully prepared and installed the virtual environment." 63 | } 64 | 65 | function replace_variables() { 66 | sed -i 's/ABNORMAL_TYPING = [^0-9]*\([0-9]\+\)/ABNORMAL_TYPING = '$NEW_ABNORMAL_TYPING'/g' src/ukip.py 67 | sed -i 's/KEYSTROKE_WINDOW = [^0-9]*\([0-9]\+\)/KEYSTROKE_WINDOW = '$NEW_KEYSTROKE_WINDOW'/g' src/ukip.py 68 | sed -i 's/_UKIP_RUN_MODE = UKIP_AVAILABLE_MODES\.\(MONITOR\|HARDENING\)/_UKIP_RUN_MODE = UKIP_AVAILABLE_MODES\.'$RUN_MODE'/g' src/ukip.py 69 | 70 | 71 | success "Successfully replaced abnormal typing and keystroke window variables in UKIP." 72 | success "Successfully set the run mode for UKIP." 73 | } 74 | 75 | function prepare_metadata() { 76 | ALLOWLIST_FILE=/etc/ukip/allowlist 77 | KEYCODES_FILE=/etc/ukip/keycodes 78 | 79 | sudo mkdir /etc/ukip/ 80 | 81 | sudo cp data/allowlist $ALLOWLIST_FILE 82 | sudo chmod 0755 $ALLOWLIST_FILE 83 | sudo chown root:root $ALLOWLIST_FILE 84 | 85 | sudo cp data/keycodes $KEYCODES_FILE 86 | sudo chmod 0755 $KEYCODES_FILE 87 | sudo chown root:root $KEYCODES_FILE 88 | 89 | success "Installed the allowlist and the keycodes file in /etc/ukip/." 90 | } 91 | 92 | function install_ukip() { 93 | UKIP_BINARY=/usr/sbin/ukip 94 | 95 | sudo cp src/ukip.py $UKIP_BINARY 96 | sudo chmod 0755 $UKIP_BINARY 97 | sudo chown root:root $UKIP_BINARY 98 | 99 | success "Installed UKIP in /usr/sbin/." 100 | } 101 | 102 | function install_systemd_service() { 103 | if $DEBIAN; then 104 | # For Debian based OSs. 105 | SYSTEMD_PATH=/lib/systemd/system/ukip.service 106 | else 107 | # For Fedora based OSs. 108 | SYSTEMD_PATH=/usr/lib/systemd/system/ukip.service 109 | fi 110 | 111 | sudo cp data/ukip.service $SYSTEMD_PATH 112 | sudo chmod 0644 $SYSTEMD_PATH 113 | sudo chown root:root $SYSTEMD_PATH 114 | 115 | sudo systemctl start ukip.service 116 | 117 | # The start and enabling sometimes race. 118 | sleep 1 119 | 120 | sudo systemctl enable ukip.service 121 | 122 | success "Installed and started systemd service." 123 | } 124 | 125 | info "Preparing and installing the virtual environment..." 126 | install_virtual_env 127 | 128 | info "Replacing keystroke window, abnormal typing speed and run mode..." 129 | replace_variables 130 | 131 | info "Preparing UKIP metadata..." 132 | prepare_metadata 133 | 134 | info "Installing UKIP..." 135 | install_ukip 136 | 137 | info "Installing and starting systemd service..." 138 | install_systemd_service 139 | 140 | success "UKIP is now installed and enabled on startup!" 141 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # USB Keystroke Injection Protection 2 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 3 | 4 | ## Overview 5 | This tool is a daemon for blocking USB keystroke injection devices on Linux systems. 6 | 7 | It supports two different modes of operation: **monitoring** and **hardening**. In 8 | monitor mode, information about a potentially attacking USB device is collected 9 | and logged to syslog. In hardening mode, the attacking USB device is ejected 10 | from the operating system by unbinding the driver. 11 | 12 | ### Installation Prerequisites 13 | The installation is mainly handled by `setup.sh`, however, there are some prerequisites 14 | that need to be adjusted before running the script: 15 | 16 | 1) Install Python3.7 or later, python dev package, virtualenv (`python3-venv`) and PIP3 (`python3-pip`) if not already 17 | available on the system. 18 | 19 | 1) Adjust the `KEYSTROKE_WINDOW` variable on top of the `setup.sh` file. This is the 20 | number of keystrokes the daemon looks at to determine whether its dealing with an attack or not. 21 | The lower the number, the higher the false positives will be (e.g., if the number is 2, the tool 22 | looks at only 1 interarrival time between those two keystrokes to determine whether it's an 23 | attack or not. Obviously, users sometimes hit two keys almost at the same time, which leads 24 | to the aforementioned false positive). Based on our internal observations, 5 is a value that 25 | is effective. However, it should be adjusted based on specific users' experiences and typing 26 | behaviour. 27 | 28 | 1) Adjust the `ABNORMAL_TYPING` variable on top of the `setup.sh` file. This variable 29 | specifies what interarrival time (between two keystrokes) should be classified as malicious. 30 | The higher the number, the more false-positives will arise (normal typing speed will be 31 | classified as malicious), where more false-negatives will arise with a lower number (even very 32 | fast typing attacks will be classified as benign). That said, the preset `50000` after initial 33 | installation is a safe default but should be changed to a number reflecting the typing speed of 34 | the user using the tool. 35 | 36 | 1) Set the mode the daemon should run in by adjusting the `RUN_MODE` variable on top of the 37 | `setup.sh` file. Setting it to `MONITOR` will send information about the USB device to a logging 38 | instance without blocking the device. Setting the variable to `HARDENING` will remove an 39 | attacking device from the system by unbinding the driver. 40 | 41 | 1) Adjust the `DEBIAN` variable on top of the `setup.sh` file. This variable indicates 42 | whether the system the tool is installed on is a Debian derivate or something else. This determination 43 | is important for the installation of the systemd service later on (the path, the service will be 44 | copied to). 45 | 46 | 1) Adjust the allowlist file in `data/allowlist`. This file will be installed to `/etc/ukip/` 47 | on your system and taken as source of truth for allowed devices, in case a device is 48 | exceeding the preset `ABNORMAL_TYPING` speed. As described in the file, the allowed device 49 | can be narrowed down with a specific set of characters to allow to even more minimize the attack 50 | surface. For example, if your keyboard uses a macro that sends `rm -rf /` allow those characters, 51 | and even an attacking device spoofing your keyboards product ID and vendor ID couldn't inject an 52 | attack (except an attack using those specific characters obviously :D ). For other cases, the 53 | `any` keyword allows all possible characters for a specified device and `none` disallows 54 | all characters. Please keep in mind that this allowlist will only be taken into consideration, if 55 | a device is exceeding the set threshold. 56 | 57 | 1) Adjust the keycodes file in `data/keycodes`. This file stores the relation between scancodes 58 | sent by the keyboard and keycodes you see on the keyboard. The default keycodes file as it is now 59 | has the scancode<->keycode layout for the US keyboard layout. If you are using a different layout, 60 | please adjust the file to fit your needs. 61 | 62 | ### Installation 63 | Once all of the above prerequisites are fulfilled, `setup.sh` should do the rest. It will install 64 | depending libraries into your users home directory (`$HOME/.ukip/`) so you don't have to install 65 | them system wide: 66 | ``` 67 | chmod +x setup.sh 68 | ./setup.sh 69 | ``` 70 | That's it: The daemon will be automatically started at boot time. 71 | 72 | For interaction with the service, the systemd interface is probably the most convenient one. 73 | To check the status: 74 | ``` 75 | systemctl status ukip.service 76 | ``` 77 | 78 | To stop the service: 79 | ``` 80 | sudo systemctl stop ukip.service 81 | ``` 82 | 83 | Alternatively, to disable the service and prevent it from being started at boot time: 84 | ``` 85 | sudo systemctl disable ukip.service 86 | ``` 87 | 88 | ## Terms of use 89 | 90 | ### USB Keystroke Injection Protection 91 | This project provides code that can be run on Linux systems to harden those systems against keystroke injection attacks, delivered via USB. 92 | The terms of use apply to data provided by Google or implicitly through code in this repository. 93 | 94 | ``` 95 | This tool hereby grants you a perpetual, worldwide, non-exclusive, 96 | no-charge, royalty-free, irrevocable copyright license to reproduce, prepare 97 | derivative works of, publicly display, publicly perform, sublicense, and 98 | distribute code in this repository related to this tool. Any copy you make for 99 | such purposes is authorized provided that you reproduce this tool's copyright 100 | designation and this license in any such copy. 101 | ``` 102 | 103 | ### Third-party Libraries 104 | This project builds upon several open source libraries. 105 | Please see each projects' Terms of use when using the provided code in this repository. 106 | 107 | ## Disclaimer 108 | **This is not an officially supported Google product.** 109 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /src/ukip.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Copyright 2020 Google LLC 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # https://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | from __future__ import absolute_import 17 | from __future__ import division 18 | from __future__ import print_function 19 | from __future__ import unicode_literals 20 | 21 | import collections 22 | import gc 23 | import json 24 | import logging 25 | import logging.handlers 26 | import sys 27 | import threading 28 | import attr 29 | import enum 30 | import evdev 31 | import pyudev 32 | from typing import Text 33 | import usb 34 | 35 | 36 | # Modes, available for UKIP to run in. Constant enum: 37 | # 1) MONITOR: Sends information about the usb device to a logging instance. 38 | # 2) HARDENING: The device gets removed from the system (drivers are unbound 39 | # from every device interface). 40 | class UKIP_AVAILABLE_MODES(enum.Enum): 41 | MONITOR = 'MONITOR' 42 | HARDENING = 'HARDENING' 43 | 44 | 45 | # The current mode, UKIP is running in. 46 | _UKIP_RUN_MODE = UKIP_AVAILABLE_MODES.HARDENING 47 | 48 | # A dict with ringbuffers as values (holding the most recent 5 keystroke times): 49 | # keys are paths to the event devices. 50 | _event_devices_timings = {} 51 | 52 | # A dict with ringbuffers as values (holding the most recent 5 keystrokes): 53 | # keys are paths to the event devices. 54 | _event_devices_keystrokes = {} 55 | 56 | # Window of keystrokes to look at. 57 | KEYSTROKE_WINDOW = 5 58 | 59 | # Abnormal typing threshold in milliseconds (Linux emits keystroke timings in 60 | # microsecond precision). 61 | # Lower: More True Positives. 62 | # Higher: More False Positives. 63 | ABNORMAL_TYPING = 50000 64 | 65 | # 1 equals KEY_DOWN in evdev. 66 | KEY_DOWN = evdev.KeyEvent.key_down 67 | 68 | # Shifts as constants for better readability. 69 | LSHIFT = 42 70 | RSHIFT = 54 71 | 72 | # Turn off duplicate logging to syslog, that would happen with the root logger. 73 | logging.basicConfig(filename='/dev/null', level=logging.DEBUG) 74 | # Now, turn on logging to syslog. 75 | log = logging.getLogger(__name__) 76 | log.setLevel(logging.DEBUG) 77 | handler = logging.handlers.SysLogHandler(address='/dev/log') 78 | log.addHandler(handler) 79 | 80 | # Global lock for _event_devices_timings and _event_devices_keystrokes dicts. 81 | _event_devices_lock = threading.Lock() 82 | 83 | 84 | @attr.s 85 | class AllowlistConfigReturn(object): 86 | """Class to represent the return value of the allowlist Config. 87 | 88 | The following return combinations are valid: 89 | 1) allowlist is a list with characters, device_present is true: the returned 90 | characters are not blocked by UKIP for the given device. 91 | 2) allowlist is an empty list, device_present is true: for the given device, 92 | any character is allowed by UKIP. 93 | 3) allowlist is an empty list, device_present is false: for the given device, 94 | no character is allowed by UKIP (either the device is not in the config 95 | file, or a user specifically marked that device with 'none' for the allowed 96 | characters). 97 | 98 | Attributes: 99 | allowlist: The returned allowlist, or empty if all characters are allowed. 100 | device_present: A boolean, whether the device was found in the config file. 101 | """ 102 | allowlist = attr.ib() # type: list 103 | device_present = attr.ib() # type: boolean 104 | 105 | 106 | @attr.s 107 | class KeycodesReturn(object): 108 | """Class to represent the return value of the keycode file read. 109 | 110 | The keycode file in /etc/ukip/keycodes contains the scancodes and ASCII 111 | codes for the selected keyboard layout. It is parsed once and read into two 112 | dicts for further processing: lower_codes and capped_codes. 113 | """ 114 | lower_codes = attr.ib() # type: dict 115 | capped_codes = attr.ib() # type: dict 116 | 117 | 118 | class DeviceError(Exception): 119 | """Generic error class for device processing.""" 120 | 121 | 122 | class AllowlistFileError(Exception): 123 | """Generic error class for allowlist processing.""" 124 | 125 | 126 | class KeycodesFileError(Exception): 127 | """Generic error class for keycode file processing.""" 128 | 129 | 130 | def add_to_ring_buffer(event_device_path: Text, key_down_time: int, 131 | keystroke: Text, device: usb.core.Device): 132 | """Add time in milliseconds to global ringbuffer. 133 | 134 | Locates the event device (/dev/input/*) in the dict of ringbuffers and adds 135 | the KEY_DOWN time in milliseconds to it. Then calls the check_for_attack 136 | function on the event device and the usb core device. 137 | 138 | Args: 139 | event_device_path: The path to the event device (/dev/input/*). 140 | key_down_time: The KEY_DOWN time in milliseconds. 141 | keystroke: The actual key typed. 142 | device: A USB device (usb.core.Device). 143 | """ 144 | with _event_devices_lock: 145 | if event_device_path not in _event_devices_timings: 146 | _event_devices_timings[event_device_path] = collections.deque( 147 | maxlen=KEYSTROKE_WINDOW) 148 | _event_devices_keystrokes[event_device_path] = collections.deque( 149 | maxlen=KEYSTROKE_WINDOW) 150 | 151 | _event_devices_timings[event_device_path].append(key_down_time) 152 | _event_devices_keystrokes[event_device_path].append(keystroke) 153 | 154 | check_for_attack(event_device_path, device) 155 | 156 | 157 | def check_local_allowlist(product_id: Text, 158 | vendor_id: Text) -> AllowlistConfigReturn: 159 | """Check local (user-based) allowlist for specifically allowed devices. 160 | 161 | UKIP users are able to specify USB devices they want to allow in a local 162 | file. This allowlist is checked, when a device is found attacking (timing 163 | threshold is exceeded) and whether that device is listed in here. If so, only 164 | the characters listed in the corresponding allowlist are allowed, the others 165 | are denied (in case of 'any' and 'none' all or no characters are allowed 166 | respectively). If the device is not listed in the allowlist, it is denied per 167 | default. 168 | 169 | Args: 170 | product_id: The required product ID to look up in the local allowlist. 171 | vendor_id: The required vendor ID to look up in the local allowlist. 172 | 173 | Raises: 174 | AllowlistFileError: When there were errors with the allowlist config file. 175 | 176 | Returns: 177 | A AllowlistConfigReturn object, with the following variations: 178 | 1) allowlist is a list with characters, device_present is true: the returned 179 | characters are not blocked by UKIP for the given device. 180 | 2) allowlist is an empty list, device_present is true: for the given device 181 | any character is allowed by UKIP. 182 | 3) allowlist is an empty list, device_present is false: for the given device 183 | no character is allowed by UKIP (either the device is not in the config 184 | file, or a user specifically marked that device with 'none' for the allowed 185 | characters). 186 | """ 187 | device = '%s:%s' % (product_id, vendor_id) 188 | 189 | try: 190 | with open('/etc/ukip/allowlist', 'r') as f: 191 | for line in f: 192 | # Comments start with '#'. 193 | if line[0] == '#': 194 | continue 195 | # Ignore empty lines. 196 | if not line.strip(): 197 | continue 198 | 199 | try: 200 | (key, val) = line.split() 201 | int(key.split(':')[0], 16) 202 | int(key.split(':')[1], 16) 203 | 204 | allowlist = val.split(',') 205 | 206 | if key != device: 207 | continue 208 | if allowlist[0] == 'any': 209 | return AllowlistConfigReturn(allowlist=[], device_present=True) 210 | if allowlist[0] == 'none': 211 | return AllowlistConfigReturn(allowlist=[], device_present=False) 212 | 213 | # If all of the checks succeed, return the allowlist (but only if it 214 | # is an allowlist, and not a word). 215 | if len(allowlist[0]) == 1: 216 | return AllowlistConfigReturn( 217 | allowlist=val.split(','), device_present=True) 218 | except (ValueError, IndexError) as vi: 219 | raise AllowlistFileError( 220 | 'The format of the config file /etc/ukip/allowlist seems to be' 221 | ' incorrect: %s' % vi) 222 | 223 | # If the device wasn't found in the file, return False. 224 | return AllowlistConfigReturn(allowlist=[], device_present=False) 225 | except FileNotFoundError as fnfe: 226 | raise AllowlistFileError( 227 | 'The config file /etc/ukip/allowlist could not be found: %s' % fnfe) 228 | 229 | 230 | def check_for_attack(event_device_path: Text, device: usb.core.Device) -> bool: 231 | """Check a ringbuffer of KEY_DOWN timings for attacks. 232 | 233 | Locates the event device (/dev/input/*) in the dict of ringbuffers and checks 234 | the correct ringbuffer for attacks (keystroke injection attack). In case of 235 | an attack, two actions can be taken, depending on the mode UKIP is running in. 236 | Those modes are specified in the UKIP_AVAILABLE_MODES enum. 237 | 238 | Args: 239 | event_device_path: The path to the event device (/dev/input/*). 240 | device: A USB device (usb.core.Device). 241 | 242 | Returns: 243 | False: If the check failed (not enough times, mode not set). None otherwise. 244 | """ 245 | with _event_devices_lock: 246 | if len(_event_devices_timings[event_device_path]) < KEYSTROKE_WINDOW: 247 | return False 248 | 249 | attack_counter = 0 250 | 251 | # Count the number of adjacent keystrokes below (or equal) the 252 | # ABNORMAL_TYPING. 253 | reversed_buffer = reversed(_event_devices_timings[event_device_path]) 254 | for value in reversed_buffer: 255 | for prev in reversed_buffer: 256 | if value - prev <= ABNORMAL_TYPING: 257 | attack_counter += 1 258 | value = prev 259 | break # Exit after the first backward iteratation. 260 | 261 | # If all the timings in the ringbuffer are within the ABNORMAL_TYPING timing. 262 | if attack_counter == KEYSTROKE_WINDOW - 1: 263 | if _UKIP_RUN_MODE == UKIP_AVAILABLE_MODES.MONITOR: 264 | enforce_monitor_mode(device, event_device_path) 265 | elif _UKIP_RUN_MODE == UKIP_AVAILABLE_MODES.HARDENING: 266 | enforce_hardening_mode(device, event_device_path) 267 | else: 268 | log.error('No run mode was specified for UKIP. Exiting...') 269 | return False 270 | 271 | 272 | def enforce_monitor_mode(device: usb.core.Device, event_device_path: Text): 273 | """Enforce the MONITOR mode on a given device. 274 | 275 | Information about devices, that would have been blocked in HARDENING mode 276 | is logged to /dev/log. 277 | 278 | Args: 279 | device: A USB device (usb.core.Device). 280 | event_device_path: The path to the event device (/dev/input/*). 281 | """ 282 | log.warning( 283 | '[UKIP] The device %s with the vendor id %s and the product id' 284 | ' %s would have been blocked. The causing timings are: %s.', 285 | device.product if device.product else 'UNKNOWN', hex(device.idVendor), 286 | hex(device.idProduct), _event_devices_timings[event_device_path]) 287 | 288 | 289 | def enforce_hardening_mode(device: usb.core.Device, event_device_path: Text): 290 | """Enforce the HARDENING mode on a given device. 291 | 292 | When enforcing the HARDENING mode, a device gets removed from the operating 293 | system when the keystrokes exceed the typing speed threshold 294 | (ABNORMAL_TYPING). This is done by unbinding the drivers from every device 295 | interface. Before the device is removed, the allowlist is checked. If the 296 | product and vendor ids are in there, the function will return and the device 297 | will continue working (possibly with a reduced allowed character set, as 298 | described in the function check_local_allowlist). 299 | 300 | Args: 301 | device: A USB device (usb.core.Device). 302 | event_device_path: The path to the event device (/dev/input/*). 303 | """ 304 | 305 | product_id = hex(device.idProduct) 306 | vendor_id = hex(device.idVendor) 307 | 308 | local_allowlist = check_local_allowlist( 309 | hex(device.idProduct), hex(device.idVendor)) 310 | 311 | # Device is present in the allowlist and all characters are allowed. 312 | if local_allowlist.device_present and not local_allowlist.allowlist: 313 | return 314 | # Device is present and an allowlist is specified. 315 | elif local_allowlist.device_present and local_allowlist.allowlist: 316 | allowlist = local_allowlist.allowlist 317 | # Device is not in the allowlist or keyword is 'none'. 318 | # i.e.: not local_allowlist.device_present and not local_allowlist.allowlist 319 | else: 320 | allowlist = [] 321 | 322 | # If all typed characters are in the allowlist, return. Otherwise run through 323 | # the rest of the function. 324 | if not set(_event_devices_keystrokes[event_device_path]).difference( 325 | set(allowlist)): 326 | return 327 | 328 | pid_and_vid = '%s:%s' % (product_id, vendor_id) 329 | 330 | for config in device: 331 | for interface in range(config.bNumInterfaces): 332 | if device.is_kernel_driver_active(interface): 333 | try: 334 | device.detach_kernel_driver(interface) 335 | 336 | if device.product: 337 | log.warning( 338 | '[UKIP] The device %s with the vendor id %s and the ' 339 | 'product id %s was blocked. The causing timings were: ' 340 | '%s.', device.product, vendor_id, product_id, 341 | _event_devices_timings[event_device_path]) 342 | else: 343 | log.warning( 344 | '[UKIP] The device with the vendor id %s and the ' 345 | 'product id %s was blocked. The causing timings were: ' 346 | '%s.', vendor_id, product_id, 347 | _event_devices_timings[event_device_path]) 348 | 349 | except (IOError, OSError, ValueError, usb.core.USBError) as e: 350 | log.warning( 351 | 'There was an error in unbinding the interface for the USB device' 352 | ' %s: %s', pid_and_vid, e) 353 | # In case of an error we still need to continue to the next interface. 354 | continue 355 | 356 | # The device was removed, so clear the dicts. Most importantly, clear the 357 | # keystroke dict. 358 | del _event_devices_timings[event_device_path] 359 | del _event_devices_keystrokes[event_device_path] 360 | gc.collect() 361 | 362 | 363 | def load_keycodes_from_file() -> KeycodesReturn: 364 | """Helper function to load the keycodes file into memory. 365 | 366 | Returns: 367 | The lowcodes and capscodes as dicts in a KeycodesReturn attribute. 368 | Raises: 369 | KeycodesFileError: If there is a problem with the keycodes file. 370 | """ 371 | lowcodes = {} 372 | capscodes = {} 373 | 374 | try: 375 | with open('/etc/ukip/keycodes', 'r') as keycode_file: 376 | try: 377 | keycodes = json.load(keycode_file) 378 | except (OverflowError, ValueError, TypeError) as je: 379 | raise KeycodesFileError('The keycodes file could not be read: %s' % je) 380 | except FileNotFoundError as fnfe: 381 | raise KeycodesFileError( 382 | 'The keycode file /etc/ukip/keycodes could not be found: %s' % fnfe) 383 | 384 | if not keycodes.get('lowcodes') or not keycodes.get('capscodes'): 385 | log.error( 386 | 'The keycodes file is missing either the lowcodes or capscodes keyword.' 387 | ) 388 | return KeycodesReturn(lower_codes=lowcodes, capped_codes=capscodes) 389 | 390 | for keycode in keycodes['lowcodes']: 391 | for scancode, lowcode in keycode.items(): 392 | lowcodes[int(scancode)] = lowcode 393 | 394 | for keycode in keycodes['capscodes']: 395 | for scancode, capcode in keycode.items(): 396 | capscodes[int(scancode)] = capcode 397 | 398 | return KeycodesReturn(lower_codes=lowcodes, capped_codes=capscodes) 399 | 400 | 401 | def monitor_device_thread(device: pyudev.Device, vendor_id: int, 402 | product_id: int) -> None: 403 | """Monitor a given USB device for occurring KEY_DOWN events. 404 | 405 | Creates a passive reading loop over a given event device and waits for 406 | KEY_DOWN events to occour. Then extracts the time in milliseconds of the event 407 | and adds it to the ringbuffer. 408 | 409 | Args: 410 | device: The event device in (/dev/input/*). 411 | vendor_id: The vendor ID of the device. 412 | product_id: The product ID of the device. 413 | 414 | Raises: 415 | OSError: If the given USB device cannot be found or if the OS receives 416 | keyboard events, after the device was unbound. Both originate from 417 | the evdev lib. 418 | StopIteration: If the iteration of the usb device tree breaks. 419 | """ 420 | keycodes = load_keycodes_from_file() 421 | lowcodes = keycodes.lower_codes 422 | capscodes = keycodes.capped_codes 423 | 424 | try: 425 | try: 426 | inputdevice = evdev.InputDevice(device.device_node) 427 | dev = usb.core.find(idVendor=vendor_id, idProduct=product_id) 428 | except (OSError, StopIteration) as mex: 429 | log.warning( 430 | 'There was an error while starting the thread for device monitoring:' 431 | ' %s', mex) 432 | 433 | # Bail the function and with that, end the thread. 434 | return 435 | 436 | log.info( 437 | f'Start monitoring {device.device_node} with the VID {hex(vendor_id)} and the PID {hex(product_id)}' 438 | ) 439 | 440 | try: 441 | # The default behaviour of evdev.InputDevice is a non-exclusive access, 442 | # so each reader gets a copy of each event. 443 | for event in inputdevice.read_loop(): 444 | caps = False 445 | 446 | for led in inputdevice.leds(verbose=True): 447 | # Check if CapsLock is turned on. 448 | if 'LED_CAPSL' in led: 449 | caps = True 450 | 451 | # LShift or RShift is either pressed or held. 452 | if LSHIFT in inputdevice.active_keys( 453 | ) or RSHIFT in inputdevice.active_keys(): 454 | caps = True 455 | 456 | if event.value == KEY_DOWN and event.type == evdev.ecodes.EV_KEY: 457 | keystroke_in_ms = (event.sec * 1000000) + event.usec 458 | 459 | if caps: 460 | keystroke = capscodes.get(evdev.categorize(event).scancode) 461 | else: 462 | keystroke = lowcodes.get(evdev.categorize(event).scancode) 463 | 464 | add_to_ring_buffer(device.device_node, keystroke_in_ms, keystroke, 465 | dev) 466 | 467 | except OSError as ose: 468 | log.warning('Events found for unbound device: %s', ose) 469 | except: 470 | log.exception('Error monitoring device.') 471 | 472 | 473 | def init_device_list() -> int: 474 | """Adds all current event devices to the global dict of event devices. 475 | 476 | Returns: 477 | The number of event devices connected, at the time UKIP was started. 478 | Raises: 479 | TypeError: If there is an error in converting the PID/VID of a USB device. 480 | ValueError: If there is an error in converting the PID/VID of a USB device. 481 | RuntimeError: If there is an error in launching the thread. 482 | DeviceError: If there is an error in creating the device list. 483 | """ 484 | 485 | device_count = 0 486 | 487 | try: 488 | local_device_context = pyudev.Context() 489 | local_device_monitor = pyudev.Monitor.from_netlink(local_device_context) 490 | local_device_monitor.filter_by(subsystem='input') 491 | except (ValueError, EnvironmentError, DeviceError) as mex: 492 | log.warning( 493 | 'There was an error creating the initial list of USB devices: %s', mex) 494 | raise DeviceError('The device context and monitor could not be created.') 495 | 496 | for device in local_device_context.list_devices(): 497 | if device.device_node and device.device_node.startswith( 498 | '/dev/input/event') and (device.get('ID_VENDOR_ID') and 499 | device.get('ID_MODEL_ID')): 500 | 501 | try: 502 | vendor_id = int(device.get('ID_VENDOR_ID'), 16) 503 | product_id = int(device.get('ID_MODEL_ID'), 16) 504 | except (TypeError, ValueError) as mex: 505 | log.error( 506 | 'There was an error in converting the PID and VID of a USB device: ' 507 | '%s', mex) 508 | continue 509 | 510 | try: 511 | threading.Thread( 512 | target=monitor_device_thread, 513 | args=(device, vendor_id, product_id)).start() 514 | device_count += 1 515 | except RuntimeError as e: 516 | log.error( 517 | 'There was an runtime error in starting the monitoring thread %s', 518 | e) 519 | 520 | return device_count 521 | 522 | 523 | def main(argv): 524 | if len(argv) > 1: 525 | sys.exit('Too many command-line arguments.') 526 | 527 | device_count = init_device_list() 528 | 529 | if not device_count: 530 | log.warning('No HID devices connected to this machine yet') 531 | 532 | ##################### 533 | # Hotplug detection # 534 | ##################### 535 | context = pyudev.Context() 536 | monitor = pyudev.Monitor.from_netlink(context) 537 | monitor.filter_by(subsystem='input') 538 | 539 | for device in iter(monitor.poll, None): 540 | try: 541 | if device.action == 'add': 542 | if device.device_node and '/dev/input/event' in device.device_node and ( 543 | device.get('ID_VENDOR_ID') and device.get('ID_MODEL_ID')): 544 | 545 | try: 546 | vendor_id = int(device.get('ID_VENDOR_ID'), 16) 547 | product_id = int(device.get('ID_MODEL_ID'), 16) 548 | except (TypeError, ValueError) as mex: 549 | log.error( 550 | 'There was an error in converting the PID and VID of a USB' 551 | ' device: %s', mex) 552 | continue 553 | 554 | threading.Thread( 555 | target=monitor_device_thread, 556 | args=(device, vendor_id, product_id)).start() 557 | except: 558 | log.exception('Error adding new device to monitoring.') 559 | 560 | 561 | if __name__ == '__main__': 562 | sys.exit(main(sys.argv)) 563 | -------------------------------------------------------------------------------- /src/ukip_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Copyright 2020 Google LLC 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # https://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | from __future__ import absolute_import 17 | from __future__ import division 18 | from __future__ import print_function 19 | 20 | import builtins 21 | import collections 22 | import gc 23 | import json 24 | import unittest.mock as mock 25 | import sys 26 | import threading 27 | import unittest 28 | import ukip 29 | import evdev 30 | import pyudev 31 | import usb 32 | 33 | 34 | sys.modules['evdev'] = mock.MagicMock() 35 | sys.modules['pyudev'] = mock.MagicMock() 36 | sys.modules['usb'] = mock.MagicMock() 37 | 38 | 39 | # This is needed, because the whole library is (Magic)mocked. 40 | # Therefore, without this an error is thrown, that usb.core.USBError is not 41 | # inheriting from BaseException. 42 | class USBError(IOError): 43 | pass 44 | 45 | 46 | class UkipTest(unittest.TestCase): 47 | 48 | def setUp(self): 49 | super(UkipTest, self).setUp() 50 | 51 | usb.core.USBError = USBError 52 | 53 | ukip._event_devices_timings = {} 54 | ukip._event_devices_keystrokes = {} 55 | 56 | class FakePyudevDevice(object): 57 | product = None 58 | device_node = None 59 | action = None 60 | ID_VENDOR_ID = None 61 | ID_MODEL_ID = None 62 | 63 | def get(self, attribute): 64 | return getattr(self, attribute) 65 | 66 | class FakeEvent(object): 67 | value = None 68 | type = None 69 | sec = None 70 | usec = None 71 | scancode = None 72 | 73 | self.pyudev_device = FakePyudevDevice() 74 | self.pyudev_device.product = 'FakeProduct' 75 | self.pyudev_device.device_node = '/dev/input/event1337' 76 | self.pyudev_device.action = 'add' 77 | # Pyudev devices emit the PID and VID as strings (hex values, but str). 78 | # Also, the PID (product ID) is called model ID (ID_MODEL_ID). 79 | self.pyudev_device.ID_VENDOR_ID = '123' 80 | self.pyudev_device.ID_MODEL_ID = '456' 81 | 82 | self.fake_event = FakeEvent() 83 | self.fake_event.value = evdev.KeyEvent.key_down 84 | self.fake_event.type = evdev.ecodes.EV_KEY 85 | self.fake_event.sec = 13 86 | self.fake_event.usec = 477827 87 | self.fake_event.scancode = 45 88 | 89 | self.mock_inputdevice = mock.create_autospec(evdev.InputDevice) 90 | 91 | self.mock_pyusb_device = mock.MagicMock() 92 | self.mock_pyusb_device.product = 'SomeVendor Keyboard' 93 | # PyUSB devices emit the PID and VID as integers. 94 | self.mock_pyusb_device.idVendor = 123 95 | self.mock_pyusb_device.idProduct = 456 96 | self.mock_pyusb_device.is_kernel_driver_active.return_value = True 97 | 98 | self.mock_usb_config = mock.create_autospec(usb.core.Configuration) 99 | self.mock_usb_config.bNumInterfaces = 1 100 | 101 | self.event_device_path = '/dev/input/event1337' 102 | 103 | evdev.InputDevice.side_effect = None 104 | 105 | @mock.patch.object(ukip, 'enforce_monitor_mode', autospec=True) 106 | def test_check_for_attack_trigger_monitor(self, monitor_mode_mock): 107 | """Tests if the monitor mode is triggered for attacking device times.""" 108 | 109 | ukip._UKIP_RUN_MODE = ukip.UKIP_AVAILABLE_MODES.MONITOR 110 | 111 | # Need to access the global variable. 112 | ukip._event_devices_timings[self.event_device_path] = collections.deque( 113 | maxlen=ukip.KEYSTROKE_WINDOW) 114 | ukip._event_devices_keystrokes[self.event_device_path] = collections.deque( 115 | maxlen=ukip.KEYSTROKE_WINDOW) 116 | 117 | # Push amount of KEYSTROKE_WINDOW times into the ringbuffer, that trigger 118 | # the monitor mode. 119 | ukip._event_devices_timings[self.event_device_path].append(1555146977759524) 120 | ukip._event_devices_timings[self.event_device_path].append(1555146977759525) 121 | ukip._event_devices_timings[self.event_device_path].append(1555146977759526) 122 | ukip._event_devices_timings[self.event_device_path].append(1555146977759527) 123 | ukip._event_devices_timings[self.event_device_path].append(1555146977759528) 124 | 125 | ukip.check_for_attack(self.event_device_path, self.mock_pyusb_device) 126 | 127 | # The timings trigger, so call the monitor mode. 128 | monitor_mode_mock.assert_called_once_with(self.mock_pyusb_device, 129 | self.event_device_path) 130 | 131 | @mock.patch.object(ukip, 'enforce_monitor_mode', autospec=True) 132 | def test_check_for_attack_not_trigger_monitor(self, monitor_mode_mock): 133 | """Tests if the monitor mode is NOT triggered for benign device times.""" 134 | 135 | ukip._UKIP_RUN_MODE = ukip.UKIP_AVAILABLE_MODES.MONITOR 136 | 137 | # Need to access the global variable. 138 | ukip._event_devices_timings[self.event_device_path] = collections.deque( 139 | maxlen=ukip.KEYSTROKE_WINDOW) 140 | 141 | # Normal typing, that doesn't trigger the monitor mode. 142 | ukip._event_devices_timings[self.event_device_path].append(1555146977759524) 143 | ukip._event_devices_timings[self.event_device_path].append(1555146980127487) 144 | ukip._event_devices_timings[self.event_device_path].append(1555146982271470) 145 | ukip._event_devices_timings[self.event_device_path].append(1555146984415453) 146 | ukip._event_devices_timings[self.event_device_path].append(1555146986559436) 147 | 148 | ukip.check_for_attack(self.event_device_path, self.mock_pyusb_device) 149 | 150 | # Since normal typing, the monitor mode was not called. 151 | self.assertFalse(monitor_mode_mock.called) 152 | 153 | @mock.patch.object(ukip, 'enforce_monitor_mode', autospec=True) 154 | def test_check_for_attack_no_times(self, monitor_mode_mock): 155 | """Checks if function returns early, if no times are provided.""" 156 | 157 | ukip._UKIP_RUN_MODE = ukip.UKIP_AVAILABLE_MODES.MONITOR 158 | 159 | ukip._event_devices_timings[self.event_device_path] = collections.deque( 160 | maxlen=ukip.KEYSTROKE_WINDOW) 161 | not_enough_timings = ukip.check_for_attack(self.event_device_path, 162 | self.mock_pyusb_device) 163 | 164 | # Not enough times, so bail out of the function call early (return False). 165 | self.assertIs(not_enough_timings, False) 166 | 167 | # When not enough times, return value is None and monitor mode is not 168 | # called. 169 | self.assertFalse(monitor_mode_mock.called) 170 | 171 | @mock.patch.object(ukip, 'enforce_hardening_mode', autospec=True) 172 | @mock.patch.object(ukip, 'enforce_monitor_mode', autospec=True) 173 | def test_check_for_attack_proper_run_mode(self, monitor_mode_mock, 174 | hardening_mode_mock): 175 | """Tests if the proper mode is executed based on global selection.""" 176 | 177 | # Need to access the global variable. 178 | ukip._event_devices_timings[self.event_device_path] = collections.deque( 179 | maxlen=ukip.KEYSTROKE_WINDOW) 180 | 181 | # Push amount of KEYSTROKE_WINDOW times into the ringbuffer, that triggers 182 | # the chosen mode. 183 | ukip._event_devices_timings[self.event_device_path].append(1555146977759524) 184 | ukip._event_devices_timings[self.event_device_path].append(1555146977759525) 185 | ukip._event_devices_timings[self.event_device_path].append(1555146977759526) 186 | ukip._event_devices_timings[self.event_device_path].append(1555146977759527) 187 | ukip._event_devices_timings[self.event_device_path].append(1555146977759528) 188 | 189 | # First test with the MONITOR mode. 190 | ukip._UKIP_RUN_MODE = ukip.UKIP_AVAILABLE_MODES.MONITOR 191 | ukip.check_for_attack(self.event_device_path, self.mock_pyusb_device) 192 | monitor_mode_mock.assert_called_once_with(self.mock_pyusb_device, 193 | self.event_device_path) 194 | 195 | # Finally, test with the HARDENING mode. 196 | ukip._UKIP_RUN_MODE = ukip.UKIP_AVAILABLE_MODES.HARDENING 197 | ukip.check_for_attack(self.event_device_path, self.mock_pyusb_device) 198 | hardening_mode_mock.assert_called_once_with(self.mock_pyusb_device, 199 | self.event_device_path) 200 | 201 | @mock.patch.object(ukip, 'log', autospec=True) 202 | @mock.patch.object(ukip, 'enforce_hardening_mode', autospec=True) 203 | @mock.patch.object(ukip, 'enforce_monitor_mode', autospec=True) 204 | def test_check_for_attack_no_run_mode(self, monitor_mode_mock, 205 | hardening_mode_mock, logging_mock): 206 | """Tests when no run mode is set.""" 207 | 208 | # Need to access the global variable. 209 | ukip._event_devices_timings[self.event_device_path] = collections.deque( 210 | maxlen=ukip.KEYSTROKE_WINDOW) 211 | 212 | # Push amount of KEYSTROKE_WINDOW times into the ringbuffer, that would 213 | # trigger a chosen mode. 214 | ukip._event_devices_timings[self.event_device_path].append(1555146977759524) 215 | ukip._event_devices_timings[self.event_device_path].append(1555146977759525) 216 | ukip._event_devices_timings[self.event_device_path].append(1555146977759526) 217 | ukip._event_devices_timings[self.event_device_path].append(1555146977759527) 218 | ukip._event_devices_timings[self.event_device_path].append(1555146977759528) 219 | 220 | # Set the run mode to None. 221 | ukip._UKIP_RUN_MODE = None 222 | ukip.check_for_attack(self.event_device_path, self.mock_pyusb_device) 223 | 224 | # No mode should trigger. 225 | self.assertFalse(monitor_mode_mock.called) 226 | self.assertFalse(hardening_mode_mock.called) 227 | 228 | # But the error should be logged. 229 | logging_mock.error.assert_called_once() 230 | 231 | @mock.patch.object(ukip, 'check_for_attack', autospec=True) 232 | def test_add_to_ring_buffer_create_key_time(self, check_for_attack_mock): 233 | """Tests the ringbuffer key creation on adding a time for the first time.""" 234 | 235 | # At the beginning the global dict is empty. 236 | self.assertFalse(ukip._event_devices_timings) 237 | 238 | # The event_device_path wasn't present, but should be created now. 239 | ukip.add_to_ring_buffer(self.event_device_path, 1555146977759524, 'x', 240 | self.mock_pyusb_device) 241 | 242 | # Check if the key was successfully created. 243 | self.assertTrue(ukip._event_devices_timings.get(self.event_device_path)) 244 | 245 | # Check if the check_for_attack function was called on the created key. 246 | check_for_attack_mock.assert_called_once_with(self.event_device_path, 247 | self.mock_pyusb_device) 248 | 249 | @mock.patch.object(ukip, 'check_for_attack', autospec=True) 250 | def test_add_to_ring_buffer_create_key_keystroke(self, check_for_attack_mock): 251 | """Tests the ringbuffer key creation on adding an initial keystroke.""" 252 | 253 | # At the beginning the global dict is empty. 254 | self.assertFalse(ukip._event_devices_keystrokes) 255 | 256 | # The event_device_path wasn't present, but should be created now. 257 | ukip.add_to_ring_buffer(self.event_device_path, 1555146977759524, 'x', 258 | self.mock_pyusb_device) 259 | 260 | # Check if the key was successfully created. 261 | self.assertTrue(ukip._event_devices_keystrokes.get(self.event_device_path)) 262 | 263 | # Check if the check_for_attack function was called on the created key. 264 | check_for_attack_mock.assert_called_once_with(self.event_device_path, 265 | self.mock_pyusb_device) 266 | 267 | @mock.patch.object(ukip, 'check_for_attack', autospec=True) 268 | def test_add_to_ring_buffer_multiple_values(self, check_for_attack_mock): 269 | """Tests if the ringbuffer is working correctly with the set window.""" 270 | 271 | ukip.add_to_ring_buffer(self.event_device_path, 1555146977759524, 'a', 272 | self.mock_pyusb_device) 273 | 274 | self.assertEqual( 275 | len(ukip._event_devices_timings.get(self.event_device_path)), 1) 276 | 277 | ukip.add_to_ring_buffer(self.event_device_path, 1555146980127487, 'b', 278 | self.mock_pyusb_device) 279 | 280 | self.assertEqual( 281 | len(ukip._event_devices_timings.get(self.event_device_path)), 2) 282 | 283 | ukip.add_to_ring_buffer(self.event_device_path, 1555146980303490, 'c', 284 | self.mock_pyusb_device) 285 | 286 | self.assertEqual( 287 | len(ukip._event_devices_timings.get(self.event_device_path)), 3) 288 | 289 | ukip.add_to_ring_buffer(self.event_device_path, 1555146982271470, 'd', 290 | self.mock_pyusb_device) 291 | 292 | self.assertEqual( 293 | len(ukip._event_devices_timings.get(self.event_device_path)), 4) 294 | 295 | ukip.add_to_ring_buffer(self.event_device_path, 1555146984271470, 'e', 296 | self.mock_pyusb_device) 297 | 298 | self.assertEqual( 299 | len(ukip._event_devices_timings.get(self.event_device_path)), 5) 300 | 301 | ukip.add_to_ring_buffer(self.event_device_path, 1555147982271470, 'f', 302 | self.mock_pyusb_device) 303 | 304 | # Since it's a ringbuffer, the length for both dicts is still 305 | # KEYSTROKE_WINDOW. 306 | self.assertEqual( 307 | len(ukip._event_devices_timings.get(self.event_device_path)), 308 | ukip.KEYSTROKE_WINDOW) 309 | self.assertEqual( 310 | len(ukip._event_devices_timings.get(self.event_device_path)), 311 | ukip.KEYSTROKE_WINDOW) 312 | 313 | # The check_for_attack function was called KEYSTROKE_WINDOW + 1 times. 314 | self.assertEqual(check_for_attack_mock.call_count, 315 | ukip.KEYSTROKE_WINDOW + 1) 316 | 317 | @mock.patch.object(ukip, 'log', autospec=True) 318 | def test_enforce_monitor_mode_with_product(self, logging_mock): 319 | """Tests which logging message is emitted when device has a product set.""" 320 | 321 | self.fill_test_ringbuffer_with_data() 322 | 323 | ukip.enforce_monitor_mode(self.mock_pyusb_device, self.event_device_path) 324 | 325 | logging_mock.warning.assert_called_with( 326 | '[UKIP] The device %s with the vendor id %s and the product' 327 | ' id %s would have been blocked. The causing timings are: %s.', 328 | self.mock_pyusb_device.product, hex(self.mock_pyusb_device.idVendor), 329 | hex(self.mock_pyusb_device.idProduct), 330 | ukip._event_devices_timings[self.event_device_path]) 331 | 332 | @mock.patch.object(ukip, 'log', autospec=True) 333 | def test_enforce_monitor_mode_no_product(self, logging_mock): 334 | """Tests which logging message is emitted when device has NO product set.""" 335 | 336 | self.fill_test_ringbuffer_with_data() 337 | self.mock_pyusb_device.product = None 338 | 339 | ukip.enforce_monitor_mode(self.mock_pyusb_device, self.event_device_path) 340 | 341 | logging_mock.warning.assert_called_with( 342 | '[UKIP] The device %s with the vendor id %s and the product' 343 | ' id %s would have been blocked. The causing timings are: %s.', 344 | 'UNKNOWN', hex(self.mock_pyusb_device.idVendor), 345 | hex(self.mock_pyusb_device.idProduct), 346 | ukip._event_devices_timings[self.event_device_path]) 347 | 348 | @mock.patch.object(ukip, 'load_keycodes_from_file', autospec=True) 349 | @mock.patch.object(evdev, 'InputDevice', autospec=True) 350 | @mock.patch.object(usb.core, 'find', autospec=True) 351 | def test_monitor_device_thread_library_calls(self, usb_core_find_mock, 352 | input_device_mock, 353 | load_keycodes_from_file_mock): 354 | """Tests if all the calls to the libraries are made.""" 355 | 356 | vendor_id = int(self.pyudev_device.ID_VENDOR_ID, 16) 357 | product_id = int(self.pyudev_device.ID_MODEL_ID, 16) 358 | 359 | ukip.monitor_device_thread(self.pyudev_device, vendor_id, product_id) 360 | 361 | load_keycodes_from_file_mock.assert_called() 362 | 363 | input_device_mock.assert_called_once_with(self.pyudev_device.device_node) 364 | usb_core_find_mock.assert_called_once_with( 365 | idVendor=vendor_id, idProduct=product_id) 366 | 367 | def test_monitor_device_thread_logging(self): 368 | """Tests the initial logging of the thread starting function.""" 369 | # TODO Implement this test. 370 | 371 | @mock.patch.object(ukip, 'load_keycodes_from_file', autospec=True) 372 | @mock.patch.object(ukip, 'log', autospec=True) 373 | def test_monitor_device_thread_exception_inputdevice( 374 | self, logging_mock, load_keycodes_from_file_mock): 375 | """Tests exception and log message for the InputDevice creation.""" 376 | log_message = ('There was an error while starting the thread for device ' 377 | 'monitoring: %s') 378 | exception_message = '[Errno 19] No such device' 379 | exception_object = OSError(exception_message) 380 | 381 | evdev.InputDevice.side_effect = exception_object 382 | 383 | vendor_id = int(self.pyudev_device.ID_VENDOR_ID, 16) 384 | product_id = int(self.pyudev_device.ID_MODEL_ID, 16) 385 | 386 | ukip.monitor_device_thread(self.pyudev_device, vendor_id, product_id) 387 | 388 | load_keycodes_from_file_mock.assert_called() 389 | 390 | logging_mock.warning.assert_called() 391 | 392 | @mock.patch.object(ukip, 'load_keycodes_from_file', autospec=True) 393 | @mock.patch.object(ukip, 'log', autospec=True) 394 | def test_monitor_device_thread_exception_read_loop( 395 | self, logging_mock, load_keycodes_from_file_mock): 396 | """Tests exception and log message in read_loop.""" 397 | log_message = 'Events found for unbound device: %s' 398 | exception_message = '[Errno 19] No such device' 399 | exception_object = OSError(exception_message) 400 | 401 | local_mock_inputdevice = mock.MagicMock() 402 | evdev.InputDevice.return_value = local_mock_inputdevice 403 | 404 | local_mock_inputdevice.read_loop.side_effect = exception_object 405 | 406 | vendor_id = int(self.pyudev_device.ID_VENDOR_ID, 16) 407 | product_id = int(self.pyudev_device.ID_MODEL_ID, 16) 408 | 409 | ukip.monitor_device_thread(self.pyudev_device, vendor_id, product_id) 410 | 411 | load_keycodes_from_file_mock.assert_called() 412 | 413 | logging_mock.warning.assert_called() 414 | 415 | def test_monitor_device_thread_keystroke_in_ms(self): 416 | """Tests if add_to_ringbuffer was called with the keystroke time in ms.""" 417 | # TODO Implement this test. 418 | 419 | def test_monitor_device_thread_keystroke_shift(self): 420 | """Tests if add_to_ringbuffer was called with the upper case keystroke.""" 421 | # TODO Implement this test. 422 | 423 | def test_monitor_device_thread_keystroke_capslock(self): 424 | """Tests if add_to_ringbuffer was called with the upper case keystroke.""" 425 | # TODO Implement this test. 426 | 427 | @mock.patch.object(pyudev, 'Context', autospec=True) 428 | @mock.patch.object(pyudev.Monitor, 'from_netlink', autospec=True) 429 | def test_init_device_list_library_calls(self, netlink_mock, context_mock): 430 | """Tests if the initial library calls are made.""" 431 | 432 | ukip.init_device_list() 433 | 434 | self.assertEqual(context_mock.call_count, 1) 435 | self.assertEqual(netlink_mock.call_count, 1) 436 | 437 | def test_init_device_list_exceptions(self): 438 | """Tests if exceptions were raised (ValueError and DeviceError).""" 439 | # TODO Implement this test. 440 | 441 | def test_init_device_list_device_count(self): 442 | """Tests if the number of devices is increased when iterating.""" 443 | # TODO Implement this test. 444 | 445 | def test_init_device_list_invalid_pid_vid(self): 446 | """Tests if a ValueError is raised, when the VID/PID cannot be converted.""" 447 | # TODO Implement this test. 448 | 449 | def test_init_device_list_runtimeerror(self): 450 | """Tests if the RuntimeError is thrown, when the thread failed to start.""" 451 | # TODO Implement this test. 452 | 453 | def test_main_threading(self): 454 | """Tests if the thread was started.""" 455 | # TODO Implement this test. 456 | 457 | def test_main_too_many_arguments(self): 458 | """Tests if no arguments were provided to main.""" 459 | # TODO Implement this test. 460 | 461 | @mock.patch.object(pyudev.Monitor, 'from_netlink', autospec=True) 462 | def test_main_filter_by(self, netlink_mock): 463 | """Tests if the monitor filter_by was actually called.""" 464 | 465 | monitor_mock = mock.MagicMock() 466 | pyudev.Monitor.from_netlink.return_value = monitor_mock 467 | monitor_mock.poll.side_effect = [self.pyudev_device, None] 468 | netlink_mock.return_value = monitor_mock 469 | 470 | ukip.main(['ukip.py']) 471 | 472 | calls = [mock.call(subsystem='input'), mock.call(subsystem='input')] 473 | monitor_mock.filter_by.assert_has_calls(calls) 474 | 475 | @mock.patch.object(builtins, 'open', autospec=True) 476 | def test_check_local_allowlist(self, open_mock): 477 | """Tests if the local allowlist check returns the allowlist on success.""" 478 | 479 | open_mock.return_value.__enter__ = open_mock 480 | 481 | # Prepare a fake file, that looks similar to the actual file. 482 | open_mock.return_value.__iter__.return_value = iter([ 483 | '# This is the config file\n', '# for UKIP.\n', 484 | '0x3784:0x3472 a,b,c\n' 485 | ]) 486 | 487 | # Call with a PID and VID that will be found. 488 | allowlist = ukip.check_local_allowlist('0x3784', '0x3472') 489 | 490 | # If the PID and VID are found, the function returns the allowlist. 491 | self.assertEqual( 492 | allowlist, 493 | ukip.AllowlistConfigReturn( 494 | allowlist=['a', 'b', 'c'], device_present=True)) 495 | 496 | @mock.patch.object(builtins, 'open', autospec=True) 497 | def test_check_local_allowlist_two_devices(self, open_mock): 498 | """Tests if the local allowlist with two devices, where one matches.""" 499 | 500 | open_mock.return_value.__enter__ = open_mock 501 | 502 | # Prepare a fake file, that looks similar to the actual file. 503 | open_mock.return_value.__iter__.return_value = iter([ 504 | '# This is the config file\n', '# for UKIP.\n', 505 | '0x1337:0x1234 x,y,z\n', '0x3784:0x3472 a,b,c\n' 506 | ]) 507 | 508 | # Call with a PID and VID that will be found. 509 | allowlist = ukip.check_local_allowlist('0x3784', '0x3472') 510 | 511 | # If the PID and VID are found, the function returns the allowlist. 512 | self.assertEqual( 513 | allowlist, 514 | ukip.AllowlistConfigReturn( 515 | allowlist=['a', 'b', 'c'], device_present=True)) 516 | 517 | @mock.patch.object(builtins, 'open', autospec=True) 518 | def test_check_local_allowlist_only_comments(self, open_mock): 519 | """Tests if the local allowlist check returns False when only comments.""" 520 | 521 | open_mock.return_value.__enter__ = open_mock 522 | 523 | # Prepare a fake file, with only comments. 524 | open_mock.return_value.__iter__.return_value = iter([ 525 | '# This is the config file\n', '# for UKIP.\n', 526 | '# One more comment line.\n' 527 | ]) 528 | 529 | # Lookup for a PID and VID. 530 | allowlist = ukip.check_local_allowlist('0x3784', '0x3472') 531 | 532 | # If there are only comment in the config file, return False. 533 | self.assertEqual( 534 | allowlist, 535 | ukip.AllowlistConfigReturn(allowlist=[], device_present=False)) 536 | 537 | @mock.patch.object(builtins, 'open', autospec=True) 538 | def test_check_local_allowlist_no_device(self, open_mock): 539 | """Tests if the allowlist check returns False when device not in file.""" 540 | 541 | open_mock.return_value.__enter__ = open_mock 542 | 543 | open_mock.return_value.__iter__.return_value = iter([ 544 | '# This is the config file\n', '# for UKIP.\n', 545 | '0x3784:0x3472 a,b,c\n' 546 | ]) 547 | 548 | # Lookup for a PID and VID which are not in the config file. 549 | allowlist = ukip.check_local_allowlist('0x1234', '0x3472') 550 | 551 | # If the device cannot be found in the config file, return False. 552 | self.assertEqual( 553 | allowlist, 554 | ukip.AllowlistConfigReturn(allowlist=[], device_present=False)) 555 | 556 | @mock.patch.object(builtins, 'open', autospec=True) 557 | def test_check_local_allowlist_key_val_parsing(self, open_mock): 558 | """Tests if the config file could be parsed into keys and values.""" 559 | 560 | open_mock.return_value.__enter__ = open_mock 561 | open_mock.return_value.__iter__.return_value = iter([ 562 | '# This is the config file\n', '# for UKIP.\n', 563 | 'cannotparse\n' 564 | ]) 565 | 566 | # Check if the exception was raised. 567 | self.assertRaises(ukip.AllowlistFileError, ukip.check_local_allowlist, 568 | '0x1234', '0x3472') 569 | 570 | @mock.patch.object(builtins, 'open', autospec=True) 571 | def test_check_local_allowlist_device_parsing(self, open_mock): 572 | """Tests if the device in the config file can be parsed.""" 573 | 574 | open_mock.return_value.__enter__ = open_mock 575 | open_mock.return_value.__iter__.return_value = iter([ 576 | '# This is the config file\n', '# for UKIP.\n', 577 | '37843472 a,b,c\n' 578 | ]) 579 | 580 | self.assertRaises(ukip.AllowlistFileError, ukip.check_local_allowlist, 581 | '0x3784', '0x3472') 582 | 583 | @mock.patch.object(builtins, 'open', autospec=True) 584 | def test_check_local_allowlist_parsing(self, open_mock): 585 | """Tests if allowlist could be parsed from the config file.""" 586 | 587 | open_mock.return_value.__enter__ = open_mock 588 | open_mock.return_value.__iter__.return_value = iter([ 589 | '# This is the config file\n', '# for UKIP.\n', 590 | '0x3784:0x3472 cannotparse\n' 591 | ]) 592 | 593 | # The device will be found, but the allowlist cannot be parsed. 594 | allowlist = ukip.check_local_allowlist('0x3784', '0x3472') 595 | 596 | # If the allowlist is a word, that is not 'any' or 'none', return False. 597 | self.assertEqual( 598 | allowlist, 599 | ukip.AllowlistConfigReturn(allowlist=[], device_present=False)) 600 | 601 | @mock.patch.object(builtins, 'open', autospec=True) 602 | def test_check_local_allowlist_file_not_found(self, open_mock): 603 | """Tests if the config file could be found.""" 604 | 605 | open_mock.side_effect = ukip.AllowlistFileError( 606 | 'The config file /etc/ukip/allowlist could not be found: %s') 607 | 608 | self.assertRaises(ukip.AllowlistFileError, ukip.check_local_allowlist, 609 | '0x3784', '0x3472') 610 | 611 | @mock.patch.object(builtins, 'open', autospec=True) 612 | def test_check_local_allowlist_empty_lines(self, open_mock): 613 | """Tests if the allowlist check returns False when only empty lines.""" 614 | 615 | open_mock.return_value.__enter__ = open_mock 616 | 617 | # Prepare a fake file, with only empty lines. 618 | open_mock.return_value.__iter__.return_value = iter( 619 | ['\n', ' \n', ' \n']) 620 | 621 | # Lookup for a PID and VID. 622 | allowlist = ukip.check_local_allowlist('0x3784', '0x3472') 623 | 624 | # If there are only empty lines in the config file, return False. 625 | self.assertEqual( 626 | allowlist, 627 | ukip.AllowlistConfigReturn(allowlist=[], device_present=False)) 628 | 629 | @mock.patch.object(builtins, 'open', autospec=True) 630 | def test_check_local_allowlist_allow_all(self, open_mock): 631 | """Tests if the allowlist check returns True for "allow all characters".""" 632 | 633 | open_mock.return_value.__enter__ = open_mock 634 | 635 | # Prepare a fake file, with only empty lines. 636 | open_mock.return_value.__iter__.return_value = iter([ 637 | '0x1234:0x1337 any\n', 638 | ]) 639 | 640 | # Lookup for a PID and VID. 641 | allowlist = ukip.check_local_allowlist('0x1234', '0x1337') 642 | 643 | # If all possible characters are allowed for a device, return an empty list 644 | # and True. 645 | self.assertEqual( 646 | allowlist, 647 | ukip.AllowlistConfigReturn(allowlist=[], device_present=True)) 648 | 649 | @mock.patch.object(builtins, 'open', autospec=True) 650 | def test_check_local_allowlist_deny_all(self, open_mock): 651 | """Tests if the allowlist is an empty list when denying all characters.""" 652 | 653 | open_mock.return_value.__enter__ = open_mock 654 | 655 | # Prepare a fake file, with only empty lines. 656 | open_mock.return_value.__iter__.return_value = iter([ 657 | '0x1234:0x1337 none\n', 658 | ]) 659 | 660 | # Lookup for a PID and VID. 661 | allowlist = ukip.check_local_allowlist('0x1234', '0x1337') 662 | 663 | # If no characters are allowed for the given device, return an empty list. 664 | self.assertEqual( 665 | allowlist, 666 | ukip.AllowlistConfigReturn(allowlist=[], device_present=False)) 667 | 668 | def fill_test_ringbuffer_with_data(self): 669 | """A helper function to add times and trigger the hardening mode.""" 670 | ukip.add_to_ring_buffer(self.event_device_path, 1555146977759524, 'a', 671 | self.mock_pyusb_device) 672 | ukip.add_to_ring_buffer(self.event_device_path, 1555146977859525, 'b', 673 | self.mock_pyusb_device) 674 | ukip.add_to_ring_buffer(self.event_device_path, 1555146977959526, 'c', 675 | self.mock_pyusb_device) 676 | ukip.add_to_ring_buffer(self.event_device_path, 1555146977959527, 'd', 677 | self.mock_pyusb_device) 678 | ukip.add_to_ring_buffer(self.event_device_path, 1555146977959528, 'e', 679 | self.mock_pyusb_device) 680 | 681 | @mock.patch.object(gc, 'collect', wraps=gc.collect) 682 | @mock.patch.object(ukip, 'check_local_allowlist', autospec=True) 683 | @mock.patch.object(ukip, 'log', autospec=True) 684 | def test_enforce_hardening_mode_with_product(self, logging_mock, 685 | check_allowlist_mock, gc_mock): 686 | """Tests which logging message is emitted when device has a product set.""" 687 | 688 | self.fill_test_ringbuffer_with_data() 689 | 690 | self.mock_pyusb_device.__iter__.return_value = iter([self.mock_usb_config]) 691 | 692 | # Need a link, because after the function is run, the dicts are deleted. 693 | timings = ukip._event_devices_timings[self.event_device_path] 694 | 695 | # Return the allowlist from /etc/ukip/allowlist. 696 | check_allowlist_mock.return_value = ukip.AllowlistConfigReturn( 697 | allowlist=['a', 'b', 'c'], device_present=True) 698 | 699 | ukip.enforce_hardening_mode(self.mock_pyusb_device, self.event_device_path) 700 | 701 | check_allowlist_mock.assert_called_once_with( 702 | hex(self.mock_pyusb_device.idProduct), 703 | hex(self.mock_pyusb_device.idVendor)) 704 | 705 | # Only 1 interface, so the range is 0. 706 | self.mock_pyusb_device.detach_kernel_driver.assert_called_once_with(0) 707 | 708 | logging_mock.warning.assert_called_with( 709 | '[UKIP] The device %s with the vendor id %s and the product id %s ' 710 | 'was blocked. The causing timings were: %s.', 711 | self.mock_pyusb_device.product, hex(self.mock_pyusb_device.idVendor), 712 | hex(self.mock_pyusb_device.idProduct), timings) 713 | 714 | # The error was not logged. 715 | self.assertFalse(logging_mock.error.called) 716 | 717 | # The dicts are deleted now. 718 | self.assertFalse(ukip._event_devices_timings) 719 | self.assertFalse(ukip._event_devices_keystrokes) 720 | 721 | # And the garbage collector ran. 722 | gc_mock.assert_called_once() 723 | 724 | @mock.patch.object(gc, 'collect', wraps=gc.collect) 725 | @mock.patch.object(ukip, 'check_local_allowlist', autospec=True) 726 | @mock.patch.object(ukip, 'log', autospec=True) 727 | def test_enforce_hardening_mode_no_product(self, logging_mock, 728 | check_allowlist_mock, gc_mock): 729 | """Tests which logging message is emitted when device has no product set.""" 730 | 731 | self.fill_test_ringbuffer_with_data() 732 | 733 | self.mock_pyusb_device.__iter__.return_value = iter([self.mock_usb_config]) 734 | self.mock_pyusb_device.product = None 735 | 736 | # Need a link, because after the function is run, the dicts are deleted. 737 | timings = ukip._event_devices_timings[self.event_device_path] 738 | 739 | # Return the allowlist from /etc/ukip/allowlist. 740 | check_allowlist_mock.return_value = ukip.AllowlistConfigReturn( 741 | allowlist=['a', 'b', 'c'], device_present=True) 742 | 743 | ukip.enforce_hardening_mode(self.mock_pyusb_device, self.event_device_path) 744 | 745 | check_allowlist_mock.assert_called_once_with( 746 | hex(self.mock_pyusb_device.idProduct), 747 | hex(self.mock_pyusb_device.idVendor)) 748 | 749 | # Only 1 interface, so the range is 0. 750 | self.mock_pyusb_device.detach_kernel_driver.assert_called_once_with(0) 751 | 752 | logging_mock.warning.assert_called_with( 753 | '[UKIP] The device with the vendor id %s and the product id %s was ' 754 | 'blocked. The causing timings were: %s.', 755 | hex(self.mock_pyusb_device.idVendor), 756 | hex(self.mock_pyusb_device.idProduct), timings) 757 | 758 | self.assertFalse(logging_mock.error.called) 759 | 760 | # The dicts are deleted now. 761 | self.assertFalse(ukip._event_devices_timings) 762 | self.assertFalse(ukip._event_devices_keystrokes) 763 | 764 | # And the garbage collector ran. 765 | gc_mock.assert_called_once() 766 | 767 | @mock.patch.object(ukip, 'check_local_allowlist', autospec=True) 768 | @mock.patch.object(ukip, 'log', autospec=True) 769 | def test_enforce_hardening_mode_no_active_driver(self, logging_mock, 770 | check_allowlist_mock): 771 | """Tests flow through function when no interface has an active driver.""" 772 | 773 | self.fill_test_ringbuffer_with_data() 774 | 775 | self.mock_pyusb_device.__iter__.return_value = iter([self.mock_usb_config]) 776 | self.mock_pyusb_device.is_kernel_driver_active.return_value = False 777 | 778 | # Return the allowlist from /etc/ukip/allowlist. 779 | check_allowlist_mock.return_value = ukip.AllowlistConfigReturn( 780 | allowlist=['a', 'b', 'c'], device_present=True) 781 | 782 | ukip.enforce_hardening_mode(self.mock_pyusb_device, self.event_device_path) 783 | 784 | check_allowlist_mock.assert_called_once_with( 785 | hex(self.mock_pyusb_device.idProduct), 786 | hex(self.mock_pyusb_device.idVendor)) 787 | 788 | self.assertFalse(self.mock_pyusb_device.detach_kernel_driver.called) 789 | self.assertFalse(logging_mock.warning.called) 790 | self.assertFalse(logging_mock.error.called) 791 | 792 | @mock.patch.object(ukip, 'check_local_allowlist', autospec=True) 793 | @mock.patch.object(ukip, 'log', autospec=True) 794 | def test_enforce_hardening_mode_ioerror(self, logging_mock, 795 | check_allowlist_mock): 796 | """Tests IOError/log message for unbinding a driver from an interface.""" 797 | 798 | self.fill_test_ringbuffer_with_data() 799 | 800 | log_message = ('There was an error in unbinding the interface for the USB ' 801 | 'device %s: %s') 802 | exception_message = '[Errno 16] Device or resource busy' 803 | exception_object = IOError(exception_message) 804 | 805 | product_id = hex(self.mock_pyusb_device.idProduct) 806 | vendor_id = hex(self.mock_pyusb_device.idVendor) 807 | pid_and_vid = '%s:%s' % (product_id, vendor_id) 808 | 809 | self.mock_pyusb_device.__iter__.return_value = iter([self.mock_usb_config]) 810 | self.mock_pyusb_device.detach_kernel_driver.side_effect = exception_object 811 | 812 | # Return the allowlist from /etc/ukip/allowlist. 813 | check_allowlist_mock.return_value = ukip.AllowlistConfigReturn( 814 | allowlist=['a', 'b', 'c'], device_present=True) 815 | 816 | ukip.enforce_hardening_mode(self.mock_pyusb_device, self.event_device_path) 817 | 818 | check_allowlist_mock.assert_called_once_with( 819 | hex(self.mock_pyusb_device.idProduct), 820 | hex(self.mock_pyusb_device.idVendor)) 821 | 822 | logging_mock.warning.assert_called() 823 | 824 | @mock.patch.object(gc, 'collect', wraps=gc.collect) 825 | @mock.patch.object(ukip, 'check_local_allowlist', autospec=True) 826 | @mock.patch.object(ukip, 'log', autospec=True) 827 | def test_enforce_hardening_mode_multiple_interfaces_error( 828 | self, logging_mock, check_allowlist_mock, gc_mock): 829 | """Tests multiple interfaces, with one failing with an IOError.""" 830 | 831 | self.fill_test_ringbuffer_with_data() 832 | 833 | log_message = ('There was an error in unbinding the interface for the USB ' 834 | 'device %s: %s') 835 | exception_message = '[Errno 16] Device or resource busy' 836 | exception_object = IOError(exception_message) 837 | 838 | product_id = hex(self.mock_pyusb_device.idProduct) 839 | vendor_id = hex(self.mock_pyusb_device.idVendor) 840 | pid_and_vid = '%s:%s' % (product_id, vendor_id) 841 | 842 | self.mock_pyusb_device.__iter__.return_value = iter([self.mock_usb_config]) 843 | self.mock_usb_config.bNumInterfaces = 2 844 | 845 | self.mock_pyusb_device.detach_kernel_driver.side_effect = [ 846 | exception_object, mock.DEFAULT 847 | ] 848 | 849 | # Need a link, because after the function is run, the dicts are deleted. 850 | timings = ukip._event_devices_timings[self.event_device_path] 851 | 852 | # Return the allowlist from /etc/ukip/allowlist. 853 | check_allowlist_mock.return_value = ukip.AllowlistConfigReturn( 854 | allowlist=['a', 'b', 'c'], device_present=True) 855 | 856 | ukip.enforce_hardening_mode(self.mock_pyusb_device, self.event_device_path) 857 | 858 | check_allowlist_mock.assert_called_once_with( 859 | hex(self.mock_pyusb_device.idProduct), 860 | hex(self.mock_pyusb_device.idVendor)) 861 | 862 | call = [ 863 | mock.call( 864 | '[UKIP] The device %s with the vendor id %s and the product id ' 865 | '%s was blocked. The causing timings were: %s.', 866 | self.mock_pyusb_device.product, 867 | hex(self.mock_pyusb_device.idVendor), 868 | hex(self.mock_pyusb_device.idProduct), timings) 869 | ] 870 | logging_mock.warning.assert_has_calls(call) 871 | 872 | # The dicts are deleted now. 873 | self.assertFalse(ukip._event_devices_timings) 874 | self.assertFalse(ukip._event_devices_keystrokes) 875 | 876 | # And the garbage collector ran. 877 | gc_mock.assert_called_once() 878 | 879 | @mock.patch.object(ukip, 'check_local_allowlist', autospec=True) 880 | @mock.patch.object(ukip, 'log', autospec=True) 881 | def test_enforce_hardening_mode_oserror(self, logging_mock, 882 | check_allowlist_mock): 883 | """Tests OSError/log message for unbinding a driver from an interface.""" 884 | 885 | self.fill_test_ringbuffer_with_data() 886 | 887 | log_message = ('There was an error in unbinding the interface for the USB ' 888 | 'device %s: %s') 889 | exception_message = 'access violation' 890 | exception_object = OSError(exception_message) 891 | 892 | product_id = hex(self.mock_pyusb_device.idProduct) 893 | vendor_id = hex(self.mock_pyusb_device.idVendor) 894 | pid_and_vid = '%s:%s' % (product_id, vendor_id) 895 | 896 | self.mock_pyusb_device.__iter__.return_value = iter([self.mock_usb_config]) 897 | self.mock_pyusb_device.detach_kernel_driver.side_effect = exception_object 898 | 899 | # Return the allowlist from /etc/ukip/allowlist. 900 | check_allowlist_mock.return_value = ukip.AllowlistConfigReturn( 901 | allowlist=['a', 'b', 'c'], device_present=True) 902 | 903 | ukip.enforce_hardening_mode(self.mock_pyusb_device, self.event_device_path) 904 | 905 | check_allowlist_mock.assert_called_once_with( 906 | hex(self.mock_pyusb_device.idProduct), 907 | hex(self.mock_pyusb_device.idVendor)) 908 | 909 | logging_mock.warning.assert_called() 910 | 911 | @mock.patch.object(ukip, 'check_local_allowlist', autospec=True) 912 | @mock.patch.object(ukip, 'log', autospec=True) 913 | def test_enforce_hardening_mode_valueerror(self, logging_mock, 914 | check_allowlist_mock): 915 | """Tests ValueError/log message for unbinding a driver from an interface.""" 916 | 917 | self.fill_test_ringbuffer_with_data() 918 | 919 | log_message = ('There was an error in unbinding the interface for the USB ' 920 | 'device %s: %s') 921 | exception_message = 'Invalid configuration' 922 | exception_object = ValueError(exception_message) 923 | 924 | product_id = hex(self.mock_pyusb_device.idProduct) 925 | vendor_id = hex(self.mock_pyusb_device.idVendor) 926 | pid_and_vid = '%s:%s' % (product_id, vendor_id) 927 | 928 | self.mock_pyusb_device.__iter__.return_value = iter([self.mock_usb_config]) 929 | self.mock_pyusb_device.detach_kernel_driver.side_effect = exception_object 930 | 931 | # Return the allowlist from /etc/ukip/allowlist. 932 | check_allowlist_mock.return_value = ukip.AllowlistConfigReturn( 933 | allowlist=['a', 'b', 'c'], device_present=True) 934 | 935 | ukip.enforce_hardening_mode(self.mock_pyusb_device, self.event_device_path) 936 | 937 | check_allowlist_mock.assert_called_once_with( 938 | hex(self.mock_pyusb_device.idProduct), 939 | hex(self.mock_pyusb_device.idVendor)) 940 | 941 | logging_mock.warning.assert_called() 942 | 943 | @mock.patch.object(ukip, 'check_local_allowlist', autospec=True) 944 | @mock.patch.object(ukip, 'log', autospec=True) 945 | def test_enforce_hardening_mode_usberror(self, logging_mock, 946 | check_allowlist_mock): 947 | """Tests USBError/log message for unbinding a driver from an interface.""" 948 | 949 | self.fill_test_ringbuffer_with_data() 950 | 951 | log_message = ('There was an error in unbinding the interface for the USB ' 952 | 'device %s: %s') 953 | exception_message = 'USBError Accessing Configurations' 954 | exception_object = usb.core.USBError(exception_message) 955 | 956 | product_id = hex(self.mock_pyusb_device.idProduct) 957 | vendor_id = hex(self.mock_pyusb_device.idVendor) 958 | pid_and_vid = '%s:%s' % (product_id, vendor_id) 959 | 960 | self.mock_pyusb_device.__iter__.return_value = iter([self.mock_usb_config]) 961 | self.mock_pyusb_device.detach_kernel_driver.side_effect = exception_object 962 | 963 | # Return the allowlist from /etc/ukip/allowlist. 964 | check_allowlist_mock.return_value = ukip.AllowlistConfigReturn( 965 | allowlist=['a', 'b', 'c'], device_present=True) 966 | 967 | ukip.enforce_hardening_mode(self.mock_pyusb_device, self.event_device_path) 968 | 969 | check_allowlist_mock.assert_called_once_with( 970 | hex(self.mock_pyusb_device.idProduct), 971 | hex(self.mock_pyusb_device.idVendor)) 972 | 973 | logging_mock.warning.assert_called() 974 | 975 | @mock.patch.object(gc, 'collect', wraps=gc.collect) 976 | @mock.patch.object(ukip, 'check_local_allowlist', autospec=True) 977 | @mock.patch.object(ukip, 'log', autospec=True) 978 | def test_enforce_hardening_mode_any_keyword(self, logging_mock, 979 | check_allowlist_mock, gc_mock): 980 | """Tests an early return if the any keyword is set in the allowlist.""" 981 | 982 | self.fill_test_ringbuffer_with_data() 983 | 984 | self.mock_pyusb_device.__iter__.return_value = iter([self.mock_usb_config]) 985 | 986 | # Device present and empty allowlist -> any keyword was set. 987 | check_allowlist_mock.return_value = ukip.AllowlistConfigReturn( 988 | allowlist=[], device_present=True) 989 | 990 | ukip.enforce_hardening_mode(self.mock_pyusb_device, self.event_device_path) 991 | 992 | check_allowlist_mock.assert_called_once_with( 993 | hex(self.mock_pyusb_device.idProduct), 994 | hex(self.mock_pyusb_device.idVendor)) 995 | 996 | # Due to the early return, none of the followup functions are called. 997 | self.assertFalse(self.mock_pyusb_device.detach_kernel_driver.called) 998 | self.assertFalse(logging_mock.called) 999 | self.assertFalse(gc_mock.called) 1000 | 1001 | @mock.patch.object(gc, 'collect', wraps=gc.collect) 1002 | @mock.patch.object(ukip, 'check_local_allowlist', autospec=True) 1003 | @mock.patch.object(ukip, 'log', autospec=True) 1004 | def test_enforce_hardening_mode_keystrokes_allowed(self, logging_mock, 1005 | check_allowlist_mock, 1006 | gc_mock): 1007 | """Tests an early return if the typed keys are allowed in the allowlist.""" 1008 | 1009 | # This sets the typed keys to [a,b,c,d,e] 1010 | self.fill_test_ringbuffer_with_data() 1011 | 1012 | self.mock_pyusb_device.__iter__.return_value = iter([self.mock_usb_config]) 1013 | 1014 | # Device present and allowlist set to typed characters. 1015 | check_allowlist_mock.return_value = ukip.AllowlistConfigReturn( 1016 | allowlist=['a', 'b', 'c', 'd', 'e'], device_present=True) 1017 | 1018 | ukip.enforce_hardening_mode(self.mock_pyusb_device, self.event_device_path) 1019 | 1020 | check_allowlist_mock.assert_called_once_with( 1021 | hex(self.mock_pyusb_device.idProduct), 1022 | hex(self.mock_pyusb_device.idVendor)) 1023 | 1024 | # Due to the early return, none of the followup functions are called. 1025 | self.assertFalse(self.mock_pyusb_device.detach_kernel_driver.called) 1026 | self.assertFalse(logging_mock.called) 1027 | self.assertFalse(gc_mock.called) 1028 | 1029 | @mock.patch.object(gc, 'collect', wraps=gc.collect) 1030 | @mock.patch.object(ukip, 'check_local_allowlist', autospec=True) 1031 | @mock.patch.object(ukip, 'log', autospec=True) 1032 | def test_enforce_hardening_mode_keystrokes_allowed_subset( 1033 | self, logging_mock, check_allowlist_mock, gc_mock): 1034 | """Tests an early return with a subset of allowed keys.""" 1035 | 1036 | ukip.add_to_ring_buffer(self.event_device_path, 1555146977759524, 'a', 1037 | self.mock_pyusb_device) 1038 | ukip.add_to_ring_buffer(self.event_device_path, 1555146977859525, 'b', 1039 | self.mock_pyusb_device) 1040 | ukip.add_to_ring_buffer(self.event_device_path, 1555146977959526, 'c', 1041 | self.mock_pyusb_device) 1042 | 1043 | self.mock_pyusb_device.__iter__.return_value = iter([self.mock_usb_config]) 1044 | 1045 | # Device present and allowlist set to typed characters. 1046 | check_allowlist_mock.return_value = ukip.AllowlistConfigReturn( 1047 | allowlist=['a', 'b', 'c', 'd', 'e', 'f'], device_present=True) 1048 | 1049 | ukip.enforce_hardening_mode(self.mock_pyusb_device, self.event_device_path) 1050 | 1051 | check_allowlist_mock.assert_called_once_with( 1052 | hex(self.mock_pyusb_device.idProduct), 1053 | hex(self.mock_pyusb_device.idVendor)) 1054 | 1055 | # Due to the early return, none of the followup functions are called. 1056 | self.assertFalse(self.mock_pyusb_device.detach_kernel_driver.called) 1057 | self.assertFalse(logging_mock.called) 1058 | self.assertFalse(gc_mock.called) 1059 | 1060 | @mock.patch.object(gc, 'collect', wraps=gc.collect) 1061 | @mock.patch.object(ukip, 'check_local_allowlist', autospec=True) 1062 | @mock.patch.object(ukip, 'log', autospec=True) 1063 | def test_enforce_hardening_mode_device_not_present(self, logging_mock, 1064 | check_allowlist_mock, 1065 | gc_mock): 1066 | """Tests function flow when the device is not present in the allowlist.""" 1067 | 1068 | self.fill_test_ringbuffer_with_data() 1069 | 1070 | self.mock_pyusb_device.__iter__.return_value = iter([self.mock_usb_config]) 1071 | 1072 | # Need a link, because after the function is run, the dicts are deleted. 1073 | timings = ukip._event_devices_timings[self.event_device_path] 1074 | 1075 | # Return the allowlist from /etc/ukip/allowlist. 1076 | check_allowlist_mock.return_value = ukip.AllowlistConfigReturn( 1077 | allowlist=[], device_present=False) 1078 | 1079 | ukip.enforce_hardening_mode(self.mock_pyusb_device, self.event_device_path) 1080 | 1081 | check_allowlist_mock.assert_called_once_with( 1082 | hex(self.mock_pyusb_device.idProduct), 1083 | hex(self.mock_pyusb_device.idVendor)) 1084 | 1085 | # Only 1 interface, so the range is 0. 1086 | self.mock_pyusb_device.detach_kernel_driver.assert_called_once_with(0) 1087 | 1088 | logging_mock.warning.assert_called_with( 1089 | '[UKIP] The device %s with the vendor id %s and the product id %s ' 1090 | 'was blocked. The causing timings were: %s.', 1091 | self.mock_pyusb_device.product, hex(self.mock_pyusb_device.idVendor), 1092 | hex(self.mock_pyusb_device.idProduct), timings) 1093 | 1094 | # The error was not logged. 1095 | self.assertFalse(logging_mock.error.called) 1096 | 1097 | # The dicts are deleted now. 1098 | self.assertFalse(ukip._event_devices_timings) 1099 | self.assertFalse(ukip._event_devices_keystrokes) 1100 | 1101 | # And the garbage collector ran. 1102 | gc_mock.assert_called_once() 1103 | 1104 | @mock.patch.object(gc, 'collect', wraps=gc.collect) 1105 | @mock.patch.object(ukip, 'check_local_allowlist', autospec=True) 1106 | @mock.patch.object(ukip, 'log', autospec=True) 1107 | def test_enforce_hardening_mode_one_key_off(self, logging_mock, 1108 | check_allowlist_mock, gc_mock): 1109 | """Tests the hardening mode when one typed key is not allowed.""" 1110 | 1111 | # This sets the typed keys to [a,b,c,d,e] 1112 | self.fill_test_ringbuffer_with_data() 1113 | 1114 | self.mock_pyusb_device.__iter__.return_value = iter([self.mock_usb_config]) 1115 | 1116 | # Need a link, because after the function is run, the dicts are deleted. 1117 | timings = ukip._event_devices_timings[self.event_device_path] 1118 | 1119 | # Return the allowlist from /etc/ukip/allowlist. The 'e' from the typed 1120 | # keys is not allowed. 1121 | check_allowlist_mock.return_value = ukip.AllowlistConfigReturn( 1122 | allowlist=['a', 'b', 'c', 'd', 'f'], device_present=False) 1123 | 1124 | ukip.enforce_hardening_mode(self.mock_pyusb_device, self.event_device_path) 1125 | 1126 | check_allowlist_mock.assert_called_once_with( 1127 | hex(self.mock_pyusb_device.idProduct), 1128 | hex(self.mock_pyusb_device.idVendor)) 1129 | 1130 | # Only 1 interface, so the range is 0. 1131 | self.mock_pyusb_device.detach_kernel_driver.assert_called_once_with(0) 1132 | 1133 | logging_mock.warning.assert_called_with( 1134 | '[UKIP] The device %s with the vendor id %s and the product id %s ' 1135 | 'was blocked. The causing timings were: %s.', 1136 | self.mock_pyusb_device.product, hex(self.mock_pyusb_device.idVendor), 1137 | hex(self.mock_pyusb_device.idProduct), timings) 1138 | 1139 | # The error was not logged. 1140 | self.assertFalse(logging_mock.error.called) 1141 | 1142 | # The dicts are deleted now. 1143 | self.assertFalse(ukip._event_devices_timings) 1144 | self.assertFalse(ukip._event_devices_keystrokes) 1145 | 1146 | # And the garbage collector ran. 1147 | gc_mock.assert_called_once() 1148 | 1149 | @mock.patch.object(ukip, 'log', autospec=True) 1150 | @mock.patch.object(builtins, 'open') 1151 | def test_load_keycodes_from_file(self, open_mock, logging_mock): 1152 | """Tests if the keycode file returns the KeycodesReturn class.""" 1153 | 1154 | handle = open_mock().__enter__.return_value 1155 | 1156 | keycode_file_content = [{ 1157 | 'lowcodes': [{ 1158 | '1': 'ESC', 1159 | '2': '1' 1160 | }], 1161 | 'capscodes': [{ 1162 | '1': 'ESC', 1163 | '2': '!' 1164 | }] 1165 | }] 1166 | 1167 | file_mock = mock.MagicMock(side_effect=keycode_file_content) 1168 | json_mock = mock.patch('json.load', file_mock) 1169 | 1170 | with open_mock: 1171 | with json_mock as json_load_mock: 1172 | keycodes = ukip.load_keycodes_from_file() 1173 | json_load_mock.assert_called_with(handle) 1174 | 1175 | self.assertEqual(keycodes.lower_codes, {1: 'ESC', 2: '1'}) 1176 | self.assertEqual(keycodes.capped_codes, {1: 'ESC', 2: '!'}) 1177 | logging_mock.assert_not_called() 1178 | 1179 | @mock.patch.object(ukip, 'log', autospec=True) 1180 | @mock.patch.object(builtins, 'open') 1181 | def test_load_keycodes_from_file_missing_keyword(self, open_mock, 1182 | logging_mock): 1183 | """Tests the keycode file returns when a keyword is missing.""" 1184 | 1185 | handle = open_mock().__enter__.return_value 1186 | 1187 | keycode_file_content = [{ 1188 | 'not_low_codes': [{ 1189 | '1': 'ESC', 1190 | '2': '1' 1191 | }], 1192 | 'capscodes': [{ 1193 | '1': 'ESC', 1194 | '2': '!' 1195 | }] 1196 | }] 1197 | 1198 | file_mock = mock.MagicMock(side_effect=keycode_file_content) 1199 | json_mock = mock.patch('json.load', file_mock) 1200 | 1201 | with open_mock: 1202 | with json_mock as json_load_mock: 1203 | keycodes = ukip.load_keycodes_from_file() 1204 | json_load_mock.assert_called_with(handle) 1205 | 1206 | # The lowcodes keyword is missing in the keycodes file. 1207 | self.assertEqual(keycodes.lower_codes, {}) 1208 | self.assertEqual(keycodes.capped_codes, {}) 1209 | logging_mock.error.assert_called() 1210 | 1211 | @mock.patch.object(ukip, 'log', autospec=True) 1212 | @mock.patch.object(json, 'load', autospec=True) 1213 | @mock.patch.object(builtins, 'open', autospec=True) 1214 | def test_load_keycodes_from_file_overflowerror(self, open_mock, json_mock, 1215 | logging_mock): 1216 | """Tests if KeycodesFileError is raised on an OverflowError.""" 1217 | 1218 | json_mock.side_effect = OverflowError 1219 | self.assertRaises(ukip.KeycodesFileError, ukip.load_keycodes_from_file) 1220 | open_mock.assert_called() 1221 | json_mock.assert_called() 1222 | logging_mock.assert_not_called() 1223 | 1224 | @mock.patch.object(ukip, 'log', autospec=True) 1225 | @mock.patch.object(json, 'load', autospec=True) 1226 | @mock.patch.object(builtins, 'open', autospec=True) 1227 | def test_load_keycodes_from_file_valueerror(self, open_mock, json_mock, 1228 | logging_mock): 1229 | """Tests if KeycodesFileError is raised on a ValueError.""" 1230 | 1231 | json_mock.side_effect = ValueError 1232 | self.assertRaises(ukip.KeycodesFileError, ukip.load_keycodes_from_file) 1233 | open_mock.assert_called() 1234 | json_mock.assert_called() 1235 | logging_mock.assert_not_called() 1236 | 1237 | @mock.patch.object(ukip, 'log', autospec=True) 1238 | @mock.patch.object(json, 'load', autospec=True) 1239 | @mock.patch.object(builtins, 'open', autospec=True) 1240 | def test_load_keycodes_from_file_typeerror(self, open_mock, json_mock, 1241 | logging_mock): 1242 | """Tests if KeycodesFileError is raised on a TypeError.""" 1243 | 1244 | json_mock.side_effect = TypeError 1245 | self.assertRaises(ukip.KeycodesFileError, ukip.load_keycodes_from_file) 1246 | open_mock.assert_called() 1247 | json_mock.assert_called() 1248 | logging_mock.assert_not_called() 1249 | 1250 | @mock.patch.object(ukip, 'log', autospec=True) 1251 | @mock.patch.object(json, 'load', autospec=True) 1252 | @mock.patch.object(builtins, 'open', autospec=True) 1253 | def test_load_keycodes_from_file_not_found(self, open_mock, json_mock, 1254 | logging_mock): 1255 | """Tests if KeycodesFileError is raised on a FileNotFoundError.""" 1256 | 1257 | json_mock.side_effect = FileNotFoundError 1258 | self.assertRaises(ukip.KeycodesFileError, ukip.load_keycodes_from_file) 1259 | open_mock.assert_called() 1260 | json_mock.assert_called() 1261 | logging_mock.assert_not_called() 1262 | 1263 | 1264 | if __name__ == '__main__': 1265 | unittest.main() 1266 | --------------------------------------------------------------------------------