Repository: google/ukip Branch: master Commit: c88410eacfe8 Files: 11 Total size: 94.3 KB Directory structure: gitextract_f7kggwew/ ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── data/ │ ├── allowlist │ ├── keycodes │ └── ukip.service ├── requirements.txt ├── setup.sh └── src/ ├── __init__.py ├── ukip.py └── ukip_test.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: CONTRIBUTING.md ================================================ # How to Contribute We'd love to accept your patches and contributions to this project. There are just a few small guidelines you need to follow. ## Contributor License Agreement Contributions to this project must be accompanied by a Contributor License Agreement. You (or your employer) retain the copyright to your contribution; this simply gives us permission to use and redistribute your contributions as part of the project. Head over to to see your current agreements on file or to sign a new one. You generally only need to submit a CLA once, so if you've already submitted one (even if it was for a different project), you probably don't need to do it again. ## Code reviews All submissions, including submissions by project members, require review. We use GitHub pull requests for this purpose. Consult [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more information on using pull requests. ## Community Guidelines This project follows [Google's Open Source Community Guidelines](https://opensource.google/conduct/). ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ # USB Keystroke Injection Protection [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) ## Overview This tool is a daemon for blocking USB keystroke injection devices on Linux systems. It supports two different modes of operation: **monitoring** and **hardening**. In monitor mode, information about a potentially attacking USB device is collected and logged to syslog. In hardening mode, the attacking USB device is ejected from the operating system by unbinding the driver. ### Installation Prerequisites The installation is mainly handled by `setup.sh`, however, there are some prerequisites that need to be adjusted before running the script: 1) Install Python3.7 or later, python dev package, virtualenv (`python3-venv`) and PIP3 (`python3-pip`) if not already available on the system. 1) Adjust the `KEYSTROKE_WINDOW` variable on top of the `setup.sh` file. This is the number of keystrokes the daemon looks at to determine whether its dealing with an attack or not. The lower the number, the higher the false positives will be (e.g., if the number is 2, the tool looks at only 1 interarrival time between those two keystrokes to determine whether it's an attack or not. Obviously, users sometimes hit two keys almost at the same time, which leads to the aforementioned false positive). Based on our internal observations, 5 is a value that is effective. However, it should be adjusted based on specific users' experiences and typing behaviour. 1) Adjust the `ABNORMAL_TYPING` variable on top of the `setup.sh` file. This variable specifies what interarrival time (between two keystrokes) should be classified as malicious. The higher the number, the more false-positives will arise (normal typing speed will be classified as malicious), where more false-negatives will arise with a lower number (even very fast typing attacks will be classified as benign). That said, the preset `50000` after initial installation is a safe default but should be changed to a number reflecting the typing speed of the user using the tool. 1) Set the mode the daemon should run in by adjusting the `RUN_MODE` variable on top of the `setup.sh` file. Setting it to `MONITOR` will send information about the USB device to a logging instance without blocking the device. Setting the variable to `HARDENING` will remove an attacking device from the system by unbinding the driver. 1) Adjust the `DEBIAN` variable on top of the `setup.sh` file. This variable indicates whether the system the tool is installed on is a Debian derivate or something else. This determination is important for the installation of the systemd service later on (the path, the service will be copied to). 1) Adjust the allowlist file in `data/allowlist`. This file will be installed to `/etc/ukip/` on your system and taken as source of truth for allowed devices, in case a device is exceeding the preset `ABNORMAL_TYPING` speed. As described in the file, the allowed device can be narrowed down with a specific set of characters to allow to even more minimize the attack surface. For example, if your keyboard uses a macro that sends `rm -rf /` allow those characters, and even an attacking device spoofing your keyboards product ID and vendor ID couldn't inject an attack (except an attack using those specific characters obviously :D ). For other cases, the `any` keyword allows all possible characters for a specified device and `none` disallows all characters. Please keep in mind that this allowlist will only be taken into consideration, if a device is exceeding the set threshold. 1) Adjust the keycodes file in `data/keycodes`. This file stores the relation between scancodes sent by the keyboard and keycodes you see on the keyboard. The default keycodes file as it is now has the scancode<->keycode layout for the US keyboard layout. If you are using a different layout, please adjust the file to fit your needs. ### Installation Once all of the above prerequisites are fulfilled, `setup.sh` should do the rest. It will install depending libraries into your users home directory (`$HOME/.ukip/`) so you don't have to install them system wide: ``` chmod +x setup.sh ./setup.sh ``` That's it: The daemon will be automatically started at boot time. For interaction with the service, the systemd interface is probably the most convenient one. To check the status: ``` systemctl status ukip.service ``` To stop the service: ``` sudo systemctl stop ukip.service ``` Alternatively, to disable the service and prevent it from being started at boot time: ``` sudo systemctl disable ukip.service ``` ## Terms of use ### USB Keystroke Injection Protection This project provides code that can be run on Linux systems to harden those systems against keystroke injection attacks, delivered via USB. The terms of use apply to data provided by Google or implicitly through code in this repository. ``` This tool hereby grants you a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute code in this repository related to this tool. Any copy you make for such purposes is authorized provided that you reproduce this tool's copyright designation and this license in any such copy. ``` ### Third-party Libraries This project builds upon several open source libraries. Please see each projects' Terms of use when using the provided code in this repository. ## Disclaimer **This is not an officially supported Google product.** ================================================ FILE: data/allowlist ================================================ # This is the allowlist for UKIP: USB Keystroke Injection Protection. # # Devices are added manually by a user in the following form, one rule per line: # : # An example for the Yubikey: 0x10:0x1050 c,b,d,e,f,g,h,i,j,k,l,n,r,t,u,v # If every character should be allowed, the product ID and vendor ID, followed # by the keyword any is sufficient. # # The following would be an example for the product ID 0x1234 and the vendor ID # 0x1337 (without the starting hashtag): # 0x1234:0x1337 any # If no character should be allowed, the approach is similar, but the keyword is # none. # # The following would be an example for the product ID 0x1337 and the vendor ID # 0x1234 (without the starting hashtag): # 0x1337:0x1234 none ================================================ FILE: data/keycodes ================================================ { "lowcodes": [{ "1": "ESC", "2": "1", "3": "2", "4": "3", "5": "4", "6": "5", "7": "6", "8": "7", "9": "8", "10": "9", "11": "0", "12": "-", "13": "=", "14": "BKSP", "15": "TAB", "16": "q", "17": "w", "18": "e", "19": "r", "20": "t", "21": "y", "22": "u", "23": "i", "24": "o", "25": "p", "26": "[", "27": "]", "28": "CRLF", "29": "LCTRL", "30": "a", "31": "s", "32": "d", "33": "f", "34": "g", "35": "h", "36": "j", "37": "k", "38": "l", "39": ";", "40": "\"", "41": "`", "42": "LSHFT", "43": "\\", "44": "z", "45": "x", "46": "c", "47": "v", "48": "b", "49": "n", "50": "m", "51": ",", "52": ".", "53": "/", "54": "RSHFT", "56": "LALT", "57": " ", "100": "RALT" }], "capscodes": [{ "1": "ESC", "2": "!", "3": "@", "4": "#", "5": "$", "6": "%", "7": "^", "8": "&", "9": "*", "10": "(", "11": ")", "12": "_", "13": "+", "14": "BKSP", "15": "TAB", "16": "Q", "17": "W", "18": "E", "19": "R", "20": "T", "21": "Y", "22": "U", "23": "I", "24": "O", "25": "P", "26": "{", "27": "}", "28": "CRLF", "29": "LCTRL", "30": "A", "31": "S", "32": "D", "33": "F", "34": "G", "35": "H", "36": "J", "37": "K", "38": "L", "39": ":", "40": "'", "41": "~", "42": "LSHFT", "43": "|", "44": "Z", "45": "X", "46": "C", "47": "V", "48": "B", "49": "N", "50": "M", "51": "<", "52": ">", "53": "?", "54": "RSHFT", "56": "LALT", "57": " ", "100": "RALT" }] } ================================================ FILE: data/ukip.service ================================================ [Unit] Description=UKIP Requires=systemd-udevd.service After=systemd-udevd.service [Service] ExecStart=/usr/sbin/ukip [Install] WantedBy=multi-user.target ================================================ FILE: requirements.txt ================================================ attrs==18.1.0 pyudev==0.21.0 attr==0.3.1 evdev==1.3.0 pyusb==1.0.2 ================================================ FILE: setup.sh ================================================ #!/bin/bash # Copyright 2019 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Replace those variables to fit your needs. NEW_KEYSTROKE_WINDOW=5 NEW_ABNORMAL_TYPING=50000 # Set either MONITOR or HARDENING. RUN_MODE=MONITOR # For systemd it's important to know which Linux flavor. DEBIAN=true # Path to virtual environment (.ukip/ in the user's home). VENV_PATH=$HOME'/.ukip/' function info() { echo -e "[\e[94m*\e[0m]" "$@" } function error() { echo -e "[\e[91m!\e[0m]" "$@" } function success() { echo -e "[\e[92m+\e[0m]" "$@" } function fatal() { error "$@" exit 1 } function install_virtual_env() { # Replace the shebang line. sed -i 's@#!/usr/bin/env python3@#!'$VENV_PATH'bin/python3@g' src/ukip.py # Install the needed virtual environemt. /usr/bin/env python3 -m venv $VENV_PATH # Activate the venv. source $VENV_PATH'bin/activate' # Install wheel before requirements. /usr/bin/env pip3 -q install wheel # Install the required packages. /usr/bin/env pip3 -q install -r requirements.txt success "Successfully prepared and installed the virtual environment." } function replace_variables() { sed -i 's/ABNORMAL_TYPING = [^0-9]*\([0-9]\+\)/ABNORMAL_TYPING = '$NEW_ABNORMAL_TYPING'/g' src/ukip.py sed -i 's/KEYSTROKE_WINDOW = [^0-9]*\([0-9]\+\)/KEYSTROKE_WINDOW = '$NEW_KEYSTROKE_WINDOW'/g' src/ukip.py sed -i 's/_UKIP_RUN_MODE = UKIP_AVAILABLE_MODES\.\(MONITOR\|HARDENING\)/_UKIP_RUN_MODE = UKIP_AVAILABLE_MODES\.'$RUN_MODE'/g' src/ukip.py success "Successfully replaced abnormal typing and keystroke window variables in UKIP." success "Successfully set the run mode for UKIP." } function prepare_metadata() { ALLOWLIST_FILE=/etc/ukip/allowlist KEYCODES_FILE=/etc/ukip/keycodes sudo mkdir /etc/ukip/ sudo cp data/allowlist $ALLOWLIST_FILE sudo chmod 0755 $ALLOWLIST_FILE sudo chown root:root $ALLOWLIST_FILE sudo cp data/keycodes $KEYCODES_FILE sudo chmod 0755 $KEYCODES_FILE sudo chown root:root $KEYCODES_FILE success "Installed the allowlist and the keycodes file in /etc/ukip/." } function install_ukip() { UKIP_BINARY=/usr/sbin/ukip sudo cp src/ukip.py $UKIP_BINARY sudo chmod 0755 $UKIP_BINARY sudo chown root:root $UKIP_BINARY success "Installed UKIP in /usr/sbin/." } function install_systemd_service() { if $DEBIAN; then # For Debian based OSs. SYSTEMD_PATH=/lib/systemd/system/ukip.service else # For Fedora based OSs. SYSTEMD_PATH=/usr/lib/systemd/system/ukip.service fi sudo cp data/ukip.service $SYSTEMD_PATH sudo chmod 0644 $SYSTEMD_PATH sudo chown root:root $SYSTEMD_PATH sudo systemctl start ukip.service # The start and enabling sometimes race. sleep 1 sudo systemctl enable ukip.service success "Installed and started systemd service." } info "Preparing and installing the virtual environment..." install_virtual_env info "Replacing keystroke window, abnormal typing speed and run mode..." replace_variables info "Preparing UKIP metadata..." prepare_metadata info "Installing UKIP..." install_ukip info "Installing and starting systemd service..." install_systemd_service success "UKIP is now installed and enabled on startup!" ================================================ FILE: src/__init__.py ================================================ ================================================ FILE: src/ukip.py ================================================ #!/usr/bin/env python3 # Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from __future__ import absolute_import from __future__ import division from __future__ import print_function from __future__ import unicode_literals import collections import gc import json import logging import logging.handlers import sys import threading import attr import enum import evdev import pyudev from typing import Text import usb # Modes, available for UKIP to run in. Constant enum: # 1) MONITOR: Sends information about the usb device to a logging instance. # 2) HARDENING: The device gets removed from the system (drivers are unbound # from every device interface). class UKIP_AVAILABLE_MODES(enum.Enum): MONITOR = 'MONITOR' HARDENING = 'HARDENING' # The current mode, UKIP is running in. _UKIP_RUN_MODE = UKIP_AVAILABLE_MODES.HARDENING # A dict with ringbuffers as values (holding the most recent 5 keystroke times): # keys are paths to the event devices. _event_devices_timings = {} # A dict with ringbuffers as values (holding the most recent 5 keystrokes): # keys are paths to the event devices. _event_devices_keystrokes = {} # Window of keystrokes to look at. KEYSTROKE_WINDOW = 5 # Abnormal typing threshold in milliseconds (Linux emits keystroke timings in # microsecond precision). # Lower: More True Positives. # Higher: More False Positives. ABNORMAL_TYPING = 50000 # 1 equals KEY_DOWN in evdev. KEY_DOWN = evdev.KeyEvent.key_down # Shifts as constants for better readability. LSHIFT = 42 RSHIFT = 54 # Turn off duplicate logging to syslog, that would happen with the root logger. logging.basicConfig(filename='/dev/null', level=logging.DEBUG) # Now, turn on logging to syslog. log = logging.getLogger(__name__) log.setLevel(logging.DEBUG) handler = logging.handlers.SysLogHandler(address='/dev/log') log.addHandler(handler) # Global lock for _event_devices_timings and _event_devices_keystrokes dicts. _event_devices_lock = threading.Lock() @attr.s class AllowlistConfigReturn(object): """Class to represent the return value of the allowlist Config. The following return combinations are valid: 1) allowlist is a list with characters, device_present is true: the returned characters are not blocked by UKIP for the given device. 2) allowlist is an empty list, device_present is true: for the given device, any character is allowed by UKIP. 3) allowlist is an empty list, device_present is false: for the given device, no character is allowed by UKIP (either the device is not in the config file, or a user specifically marked that device with 'none' for the allowed characters). Attributes: allowlist: The returned allowlist, or empty if all characters are allowed. device_present: A boolean, whether the device was found in the config file. """ allowlist = attr.ib() # type: list device_present = attr.ib() # type: boolean @attr.s class KeycodesReturn(object): """Class to represent the return value of the keycode file read. The keycode file in /etc/ukip/keycodes contains the scancodes and ASCII codes for the selected keyboard layout. It is parsed once and read into two dicts for further processing: lower_codes and capped_codes. """ lower_codes = attr.ib() # type: dict capped_codes = attr.ib() # type: dict class DeviceError(Exception): """Generic error class for device processing.""" class AllowlistFileError(Exception): """Generic error class for allowlist processing.""" class KeycodesFileError(Exception): """Generic error class for keycode file processing.""" def add_to_ring_buffer(event_device_path: Text, key_down_time: int, keystroke: Text, device: usb.core.Device): """Add time in milliseconds to global ringbuffer. Locates the event device (/dev/input/*) in the dict of ringbuffers and adds the KEY_DOWN time in milliseconds to it. Then calls the check_for_attack function on the event device and the usb core device. Args: event_device_path: The path to the event device (/dev/input/*). key_down_time: The KEY_DOWN time in milliseconds. keystroke: The actual key typed. device: A USB device (usb.core.Device). """ with _event_devices_lock: if event_device_path not in _event_devices_timings: _event_devices_timings[event_device_path] = collections.deque( maxlen=KEYSTROKE_WINDOW) _event_devices_keystrokes[event_device_path] = collections.deque( maxlen=KEYSTROKE_WINDOW) _event_devices_timings[event_device_path].append(key_down_time) _event_devices_keystrokes[event_device_path].append(keystroke) check_for_attack(event_device_path, device) def check_local_allowlist(product_id: Text, vendor_id: Text) -> AllowlistConfigReturn: """Check local (user-based) allowlist for specifically allowed devices. UKIP users are able to specify USB devices they want to allow in a local file. This allowlist is checked, when a device is found attacking (timing threshold is exceeded) and whether that device is listed in here. If so, only the characters listed in the corresponding allowlist are allowed, the others are denied (in case of 'any' and 'none' all or no characters are allowed respectively). If the device is not listed in the allowlist, it is denied per default. Args: product_id: The required product ID to look up in the local allowlist. vendor_id: The required vendor ID to look up in the local allowlist. Raises: AllowlistFileError: When there were errors with the allowlist config file. Returns: A AllowlistConfigReturn object, with the following variations: 1) allowlist is a list with characters, device_present is true: the returned characters are not blocked by UKIP for the given device. 2) allowlist is an empty list, device_present is true: for the given device any character is allowed by UKIP. 3) allowlist is an empty list, device_present is false: for the given device no character is allowed by UKIP (either the device is not in the config file, or a user specifically marked that device with 'none' for the allowed characters). """ device = '%s:%s' % (product_id, vendor_id) try: with open('/etc/ukip/allowlist', 'r') as f: for line in f: # Comments start with '#'. if line[0] == '#': continue # Ignore empty lines. if not line.strip(): continue try: (key, val) = line.split() int(key.split(':')[0], 16) int(key.split(':')[1], 16) allowlist = val.split(',') if key != device: continue if allowlist[0] == 'any': return AllowlistConfigReturn(allowlist=[], device_present=True) if allowlist[0] == 'none': return AllowlistConfigReturn(allowlist=[], device_present=False) # If all of the checks succeed, return the allowlist (but only if it # is an allowlist, and not a word). if len(allowlist[0]) == 1: return AllowlistConfigReturn( allowlist=val.split(','), device_present=True) except (ValueError, IndexError) as vi: raise AllowlistFileError( 'The format of the config file /etc/ukip/allowlist seems to be' ' incorrect: %s' % vi) # If the device wasn't found in the file, return False. return AllowlistConfigReturn(allowlist=[], device_present=False) except FileNotFoundError as fnfe: raise AllowlistFileError( 'The config file /etc/ukip/allowlist could not be found: %s' % fnfe) def check_for_attack(event_device_path: Text, device: usb.core.Device) -> bool: """Check a ringbuffer of KEY_DOWN timings for attacks. Locates the event device (/dev/input/*) in the dict of ringbuffers and checks the correct ringbuffer for attacks (keystroke injection attack). In case of an attack, two actions can be taken, depending on the mode UKIP is running in. Those modes are specified in the UKIP_AVAILABLE_MODES enum. Args: event_device_path: The path to the event device (/dev/input/*). device: A USB device (usb.core.Device). Returns: False: If the check failed (not enough times, mode not set). None otherwise. """ with _event_devices_lock: if len(_event_devices_timings[event_device_path]) < KEYSTROKE_WINDOW: return False attack_counter = 0 # Count the number of adjacent keystrokes below (or equal) the # ABNORMAL_TYPING. reversed_buffer = reversed(_event_devices_timings[event_device_path]) for value in reversed_buffer: for prev in reversed_buffer: if value - prev <= ABNORMAL_TYPING: attack_counter += 1 value = prev break # Exit after the first backward iteratation. # If all the timings in the ringbuffer are within the ABNORMAL_TYPING timing. if attack_counter == KEYSTROKE_WINDOW - 1: if _UKIP_RUN_MODE == UKIP_AVAILABLE_MODES.MONITOR: enforce_monitor_mode(device, event_device_path) elif _UKIP_RUN_MODE == UKIP_AVAILABLE_MODES.HARDENING: enforce_hardening_mode(device, event_device_path) else: log.error('No run mode was specified for UKIP. Exiting...') return False def enforce_monitor_mode(device: usb.core.Device, event_device_path: Text): """Enforce the MONITOR mode on a given device. Information about devices, that would have been blocked in HARDENING mode is logged to /dev/log. Args: device: A USB device (usb.core.Device). event_device_path: The path to the event device (/dev/input/*). """ log.warning( '[UKIP] The device %s with the vendor id %s and the product id' ' %s would have been blocked. The causing timings are: %s.', device.product if device.product else 'UNKNOWN', hex(device.idVendor), hex(device.idProduct), _event_devices_timings[event_device_path]) def enforce_hardening_mode(device: usb.core.Device, event_device_path: Text): """Enforce the HARDENING mode on a given device. When enforcing the HARDENING mode, a device gets removed from the operating system when the keystrokes exceed the typing speed threshold (ABNORMAL_TYPING). This is done by unbinding the drivers from every device interface. Before the device is removed, the allowlist is checked. If the product and vendor ids are in there, the function will return and the device will continue working (possibly with a reduced allowed character set, as described in the function check_local_allowlist). Args: device: A USB device (usb.core.Device). event_device_path: The path to the event device (/dev/input/*). """ product_id = hex(device.idProduct) vendor_id = hex(device.idVendor) local_allowlist = check_local_allowlist( hex(device.idProduct), hex(device.idVendor)) # Device is present in the allowlist and all characters are allowed. if local_allowlist.device_present and not local_allowlist.allowlist: return # Device is present and an allowlist is specified. elif local_allowlist.device_present and local_allowlist.allowlist: allowlist = local_allowlist.allowlist # Device is not in the allowlist or keyword is 'none'. # i.e.: not local_allowlist.device_present and not local_allowlist.allowlist else: allowlist = [] # If all typed characters are in the allowlist, return. Otherwise run through # the rest of the function. if not set(_event_devices_keystrokes[event_device_path]).difference( set(allowlist)): return pid_and_vid = '%s:%s' % (product_id, vendor_id) for config in device: for interface in range(config.bNumInterfaces): if device.is_kernel_driver_active(interface): try: device.detach_kernel_driver(interface) if device.product: log.warning( '[UKIP] The device %s with the vendor id %s and the ' 'product id %s was blocked. The causing timings were: ' '%s.', device.product, vendor_id, product_id, _event_devices_timings[event_device_path]) else: log.warning( '[UKIP] The device with the vendor id %s and the ' 'product id %s was blocked. The causing timings were: ' '%s.', vendor_id, product_id, _event_devices_timings[event_device_path]) except (IOError, OSError, ValueError, usb.core.USBError) as e: log.warning( 'There was an error in unbinding the interface for the USB device' ' %s: %s', pid_and_vid, e) # In case of an error we still need to continue to the next interface. continue # The device was removed, so clear the dicts. Most importantly, clear the # keystroke dict. del _event_devices_timings[event_device_path] del _event_devices_keystrokes[event_device_path] gc.collect() def load_keycodes_from_file() -> KeycodesReturn: """Helper function to load the keycodes file into memory. Returns: The lowcodes and capscodes as dicts in a KeycodesReturn attribute. Raises: KeycodesFileError: If there is a problem with the keycodes file. """ lowcodes = {} capscodes = {} try: with open('/etc/ukip/keycodes', 'r') as keycode_file: try: keycodes = json.load(keycode_file) except (OverflowError, ValueError, TypeError) as je: raise KeycodesFileError('The keycodes file could not be read: %s' % je) except FileNotFoundError as fnfe: raise KeycodesFileError( 'The keycode file /etc/ukip/keycodes could not be found: %s' % fnfe) if not keycodes.get('lowcodes') or not keycodes.get('capscodes'): log.error( 'The keycodes file is missing either the lowcodes or capscodes keyword.' ) return KeycodesReturn(lower_codes=lowcodes, capped_codes=capscodes) for keycode in keycodes['lowcodes']: for scancode, lowcode in keycode.items(): lowcodes[int(scancode)] = lowcode for keycode in keycodes['capscodes']: for scancode, capcode in keycode.items(): capscodes[int(scancode)] = capcode return KeycodesReturn(lower_codes=lowcodes, capped_codes=capscodes) def monitor_device_thread(device: pyudev.Device, vendor_id: int, product_id: int) -> None: """Monitor a given USB device for occurring KEY_DOWN events. Creates a passive reading loop over a given event device and waits for KEY_DOWN events to occour. Then extracts the time in milliseconds of the event and adds it to the ringbuffer. Args: device: The event device in (/dev/input/*). vendor_id: The vendor ID of the device. product_id: The product ID of the device. Raises: OSError: If the given USB device cannot be found or if the OS receives keyboard events, after the device was unbound. Both originate from the evdev lib. StopIteration: If the iteration of the usb device tree breaks. """ keycodes = load_keycodes_from_file() lowcodes = keycodes.lower_codes capscodes = keycodes.capped_codes try: try: inputdevice = evdev.InputDevice(device.device_node) dev = usb.core.find(idVendor=vendor_id, idProduct=product_id) except (OSError, StopIteration) as mex: log.warning( 'There was an error while starting the thread for device monitoring:' ' %s', mex) # Bail the function and with that, end the thread. return log.info( f'Start monitoring {device.device_node} with the VID {hex(vendor_id)} and the PID {hex(product_id)}' ) try: # The default behaviour of evdev.InputDevice is a non-exclusive access, # so each reader gets a copy of each event. for event in inputdevice.read_loop(): caps = False for led in inputdevice.leds(verbose=True): # Check if CapsLock is turned on. if 'LED_CAPSL' in led: caps = True # LShift or RShift is either pressed or held. if LSHIFT in inputdevice.active_keys( ) or RSHIFT in inputdevice.active_keys(): caps = True if event.value == KEY_DOWN and event.type == evdev.ecodes.EV_KEY: keystroke_in_ms = (event.sec * 1000000) + event.usec if caps: keystroke = capscodes.get(evdev.categorize(event).scancode) else: keystroke = lowcodes.get(evdev.categorize(event).scancode) add_to_ring_buffer(device.device_node, keystroke_in_ms, keystroke, dev) except OSError as ose: log.warning('Events found for unbound device: %s', ose) except: log.exception('Error monitoring device.') def init_device_list() -> int: """Adds all current event devices to the global dict of event devices. Returns: The number of event devices connected, at the time UKIP was started. Raises: TypeError: If there is an error in converting the PID/VID of a USB device. ValueError: If there is an error in converting the PID/VID of a USB device. RuntimeError: If there is an error in launching the thread. DeviceError: If there is an error in creating the device list. """ device_count = 0 try: local_device_context = pyudev.Context() local_device_monitor = pyudev.Monitor.from_netlink(local_device_context) local_device_monitor.filter_by(subsystem='input') except (ValueError, EnvironmentError, DeviceError) as mex: log.warning( 'There was an error creating the initial list of USB devices: %s', mex) raise DeviceError('The device context and monitor could not be created.') for device in local_device_context.list_devices(): if device.device_node and device.device_node.startswith( '/dev/input/event') and (device.get('ID_VENDOR_ID') and device.get('ID_MODEL_ID')): try: vendor_id = int(device.get('ID_VENDOR_ID'), 16) product_id = int(device.get('ID_MODEL_ID'), 16) except (TypeError, ValueError) as mex: log.error( 'There was an error in converting the PID and VID of a USB device: ' '%s', mex) continue try: threading.Thread( target=monitor_device_thread, args=(device, vendor_id, product_id)).start() device_count += 1 except RuntimeError as e: log.error( 'There was an runtime error in starting the monitoring thread %s', e) return device_count def main(argv): if len(argv) > 1: sys.exit('Too many command-line arguments.') device_count = init_device_list() if not device_count: log.warning('No HID devices connected to this machine yet') ##################### # Hotplug detection # ##################### context = pyudev.Context() monitor = pyudev.Monitor.from_netlink(context) monitor.filter_by(subsystem='input') for device in iter(monitor.poll, None): try: if device.action == 'add': if device.device_node and '/dev/input/event' in device.device_node and ( device.get('ID_VENDOR_ID') and device.get('ID_MODEL_ID')): try: vendor_id = int(device.get('ID_VENDOR_ID'), 16) product_id = int(device.get('ID_MODEL_ID'), 16) except (TypeError, ValueError) as mex: log.error( 'There was an error in converting the PID and VID of a USB' ' device: %s', mex) continue threading.Thread( target=monitor_device_thread, args=(device, vendor_id, product_id)).start() except: log.exception('Error adding new device to monitoring.') if __name__ == '__main__': sys.exit(main(sys.argv)) ================================================ FILE: src/ukip_test.py ================================================ #!/usr/bin/env python3 # Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from __future__ import absolute_import from __future__ import division from __future__ import print_function import builtins import collections import gc import json import unittest.mock as mock import sys import threading import unittest import ukip import evdev import pyudev import usb sys.modules['evdev'] = mock.MagicMock() sys.modules['pyudev'] = mock.MagicMock() sys.modules['usb'] = mock.MagicMock() # This is needed, because the whole library is (Magic)mocked. # Therefore, without this an error is thrown, that usb.core.USBError is not # inheriting from BaseException. class USBError(IOError): pass class UkipTest(unittest.TestCase): def setUp(self): super(UkipTest, self).setUp() usb.core.USBError = USBError ukip._event_devices_timings = {} ukip._event_devices_keystrokes = {} class FakePyudevDevice(object): product = None device_node = None action = None ID_VENDOR_ID = None ID_MODEL_ID = None def get(self, attribute): return getattr(self, attribute) class FakeEvent(object): value = None type = None sec = None usec = None scancode = None self.pyudev_device = FakePyudevDevice() self.pyudev_device.product = 'FakeProduct' self.pyudev_device.device_node = '/dev/input/event1337' self.pyudev_device.action = 'add' # Pyudev devices emit the PID and VID as strings (hex values, but str). # Also, the PID (product ID) is called model ID (ID_MODEL_ID). self.pyudev_device.ID_VENDOR_ID = '123' self.pyudev_device.ID_MODEL_ID = '456' self.fake_event = FakeEvent() self.fake_event.value = evdev.KeyEvent.key_down self.fake_event.type = evdev.ecodes.EV_KEY self.fake_event.sec = 13 self.fake_event.usec = 477827 self.fake_event.scancode = 45 self.mock_inputdevice = mock.create_autospec(evdev.InputDevice) self.mock_pyusb_device = mock.MagicMock() self.mock_pyusb_device.product = 'SomeVendor Keyboard' # PyUSB devices emit the PID and VID as integers. self.mock_pyusb_device.idVendor = 123 self.mock_pyusb_device.idProduct = 456 self.mock_pyusb_device.is_kernel_driver_active.return_value = True self.mock_usb_config = mock.create_autospec(usb.core.Configuration) self.mock_usb_config.bNumInterfaces = 1 self.event_device_path = '/dev/input/event1337' evdev.InputDevice.side_effect = None @mock.patch.object(ukip, 'enforce_monitor_mode', autospec=True) def test_check_for_attack_trigger_monitor(self, monitor_mode_mock): """Tests if the monitor mode is triggered for attacking device times.""" ukip._UKIP_RUN_MODE = ukip.UKIP_AVAILABLE_MODES.MONITOR # Need to access the global variable. ukip._event_devices_timings[self.event_device_path] = collections.deque( maxlen=ukip.KEYSTROKE_WINDOW) ukip._event_devices_keystrokes[self.event_device_path] = collections.deque( maxlen=ukip.KEYSTROKE_WINDOW) # Push amount of KEYSTROKE_WINDOW times into the ringbuffer, that trigger # the monitor mode. ukip._event_devices_timings[self.event_device_path].append(1555146977759524) ukip._event_devices_timings[self.event_device_path].append(1555146977759525) ukip._event_devices_timings[self.event_device_path].append(1555146977759526) ukip._event_devices_timings[self.event_device_path].append(1555146977759527) ukip._event_devices_timings[self.event_device_path].append(1555146977759528) ukip.check_for_attack(self.event_device_path, self.mock_pyusb_device) # The timings trigger, so call the monitor mode. monitor_mode_mock.assert_called_once_with(self.mock_pyusb_device, self.event_device_path) @mock.patch.object(ukip, 'enforce_monitor_mode', autospec=True) def test_check_for_attack_not_trigger_monitor(self, monitor_mode_mock): """Tests if the monitor mode is NOT triggered for benign device times.""" ukip._UKIP_RUN_MODE = ukip.UKIP_AVAILABLE_MODES.MONITOR # Need to access the global variable. ukip._event_devices_timings[self.event_device_path] = collections.deque( maxlen=ukip.KEYSTROKE_WINDOW) # Normal typing, that doesn't trigger the monitor mode. ukip._event_devices_timings[self.event_device_path].append(1555146977759524) ukip._event_devices_timings[self.event_device_path].append(1555146980127487) ukip._event_devices_timings[self.event_device_path].append(1555146982271470) ukip._event_devices_timings[self.event_device_path].append(1555146984415453) ukip._event_devices_timings[self.event_device_path].append(1555146986559436) ukip.check_for_attack(self.event_device_path, self.mock_pyusb_device) # Since normal typing, the monitor mode was not called. self.assertFalse(monitor_mode_mock.called) @mock.patch.object(ukip, 'enforce_monitor_mode', autospec=True) def test_check_for_attack_no_times(self, monitor_mode_mock): """Checks if function returns early, if no times are provided.""" ukip._UKIP_RUN_MODE = ukip.UKIP_AVAILABLE_MODES.MONITOR ukip._event_devices_timings[self.event_device_path] = collections.deque( maxlen=ukip.KEYSTROKE_WINDOW) not_enough_timings = ukip.check_for_attack(self.event_device_path, self.mock_pyusb_device) # Not enough times, so bail out of the function call early (return False). self.assertIs(not_enough_timings, False) # When not enough times, return value is None and monitor mode is not # called. self.assertFalse(monitor_mode_mock.called) @mock.patch.object(ukip, 'enforce_hardening_mode', autospec=True) @mock.patch.object(ukip, 'enforce_monitor_mode', autospec=True) def test_check_for_attack_proper_run_mode(self, monitor_mode_mock, hardening_mode_mock): """Tests if the proper mode is executed based on global selection.""" # Need to access the global variable. ukip._event_devices_timings[self.event_device_path] = collections.deque( maxlen=ukip.KEYSTROKE_WINDOW) # Push amount of KEYSTROKE_WINDOW times into the ringbuffer, that triggers # the chosen mode. ukip._event_devices_timings[self.event_device_path].append(1555146977759524) ukip._event_devices_timings[self.event_device_path].append(1555146977759525) ukip._event_devices_timings[self.event_device_path].append(1555146977759526) ukip._event_devices_timings[self.event_device_path].append(1555146977759527) ukip._event_devices_timings[self.event_device_path].append(1555146977759528) # First test with the MONITOR mode. ukip._UKIP_RUN_MODE = ukip.UKIP_AVAILABLE_MODES.MONITOR ukip.check_for_attack(self.event_device_path, self.mock_pyusb_device) monitor_mode_mock.assert_called_once_with(self.mock_pyusb_device, self.event_device_path) # Finally, test with the HARDENING mode. ukip._UKIP_RUN_MODE = ukip.UKIP_AVAILABLE_MODES.HARDENING ukip.check_for_attack(self.event_device_path, self.mock_pyusb_device) hardening_mode_mock.assert_called_once_with(self.mock_pyusb_device, self.event_device_path) @mock.patch.object(ukip, 'log', autospec=True) @mock.patch.object(ukip, 'enforce_hardening_mode', autospec=True) @mock.patch.object(ukip, 'enforce_monitor_mode', autospec=True) def test_check_for_attack_no_run_mode(self, monitor_mode_mock, hardening_mode_mock, logging_mock): """Tests when no run mode is set.""" # Need to access the global variable. ukip._event_devices_timings[self.event_device_path] = collections.deque( maxlen=ukip.KEYSTROKE_WINDOW) # Push amount of KEYSTROKE_WINDOW times into the ringbuffer, that would # trigger a chosen mode. ukip._event_devices_timings[self.event_device_path].append(1555146977759524) ukip._event_devices_timings[self.event_device_path].append(1555146977759525) ukip._event_devices_timings[self.event_device_path].append(1555146977759526) ukip._event_devices_timings[self.event_device_path].append(1555146977759527) ukip._event_devices_timings[self.event_device_path].append(1555146977759528) # Set the run mode to None. ukip._UKIP_RUN_MODE = None ukip.check_for_attack(self.event_device_path, self.mock_pyusb_device) # No mode should trigger. self.assertFalse(monitor_mode_mock.called) self.assertFalse(hardening_mode_mock.called) # But the error should be logged. logging_mock.error.assert_called_once() @mock.patch.object(ukip, 'check_for_attack', autospec=True) def test_add_to_ring_buffer_create_key_time(self, check_for_attack_mock): """Tests the ringbuffer key creation on adding a time for the first time.""" # At the beginning the global dict is empty. self.assertFalse(ukip._event_devices_timings) # The event_device_path wasn't present, but should be created now. ukip.add_to_ring_buffer(self.event_device_path, 1555146977759524, 'x', self.mock_pyusb_device) # Check if the key was successfully created. self.assertTrue(ukip._event_devices_timings.get(self.event_device_path)) # Check if the check_for_attack function was called on the created key. check_for_attack_mock.assert_called_once_with(self.event_device_path, self.mock_pyusb_device) @mock.patch.object(ukip, 'check_for_attack', autospec=True) def test_add_to_ring_buffer_create_key_keystroke(self, check_for_attack_mock): """Tests the ringbuffer key creation on adding an initial keystroke.""" # At the beginning the global dict is empty. self.assertFalse(ukip._event_devices_keystrokes) # The event_device_path wasn't present, but should be created now. ukip.add_to_ring_buffer(self.event_device_path, 1555146977759524, 'x', self.mock_pyusb_device) # Check if the key was successfully created. self.assertTrue(ukip._event_devices_keystrokes.get(self.event_device_path)) # Check if the check_for_attack function was called on the created key. check_for_attack_mock.assert_called_once_with(self.event_device_path, self.mock_pyusb_device) @mock.patch.object(ukip, 'check_for_attack', autospec=True) def test_add_to_ring_buffer_multiple_values(self, check_for_attack_mock): """Tests if the ringbuffer is working correctly with the set window.""" ukip.add_to_ring_buffer(self.event_device_path, 1555146977759524, 'a', self.mock_pyusb_device) self.assertEqual( len(ukip._event_devices_timings.get(self.event_device_path)), 1) ukip.add_to_ring_buffer(self.event_device_path, 1555146980127487, 'b', self.mock_pyusb_device) self.assertEqual( len(ukip._event_devices_timings.get(self.event_device_path)), 2) ukip.add_to_ring_buffer(self.event_device_path, 1555146980303490, 'c', self.mock_pyusb_device) self.assertEqual( len(ukip._event_devices_timings.get(self.event_device_path)), 3) ukip.add_to_ring_buffer(self.event_device_path, 1555146982271470, 'd', self.mock_pyusb_device) self.assertEqual( len(ukip._event_devices_timings.get(self.event_device_path)), 4) ukip.add_to_ring_buffer(self.event_device_path, 1555146984271470, 'e', self.mock_pyusb_device) self.assertEqual( len(ukip._event_devices_timings.get(self.event_device_path)), 5) ukip.add_to_ring_buffer(self.event_device_path, 1555147982271470, 'f', self.mock_pyusb_device) # Since it's a ringbuffer, the length for both dicts is still # KEYSTROKE_WINDOW. self.assertEqual( len(ukip._event_devices_timings.get(self.event_device_path)), ukip.KEYSTROKE_WINDOW) self.assertEqual( len(ukip._event_devices_timings.get(self.event_device_path)), ukip.KEYSTROKE_WINDOW) # The check_for_attack function was called KEYSTROKE_WINDOW + 1 times. self.assertEqual(check_for_attack_mock.call_count, ukip.KEYSTROKE_WINDOW + 1) @mock.patch.object(ukip, 'log', autospec=True) def test_enforce_monitor_mode_with_product(self, logging_mock): """Tests which logging message is emitted when device has a product set.""" self.fill_test_ringbuffer_with_data() ukip.enforce_monitor_mode(self.mock_pyusb_device, self.event_device_path) logging_mock.warning.assert_called_with( '[UKIP] The device %s with the vendor id %s and the product' ' id %s would have been blocked. The causing timings are: %s.', self.mock_pyusb_device.product, hex(self.mock_pyusb_device.idVendor), hex(self.mock_pyusb_device.idProduct), ukip._event_devices_timings[self.event_device_path]) @mock.patch.object(ukip, 'log', autospec=True) def test_enforce_monitor_mode_no_product(self, logging_mock): """Tests which logging message is emitted when device has NO product set.""" self.fill_test_ringbuffer_with_data() self.mock_pyusb_device.product = None ukip.enforce_monitor_mode(self.mock_pyusb_device, self.event_device_path) logging_mock.warning.assert_called_with( '[UKIP] The device %s with the vendor id %s and the product' ' id %s would have been blocked. The causing timings are: %s.', 'UNKNOWN', hex(self.mock_pyusb_device.idVendor), hex(self.mock_pyusb_device.idProduct), ukip._event_devices_timings[self.event_device_path]) @mock.patch.object(ukip, 'load_keycodes_from_file', autospec=True) @mock.patch.object(evdev, 'InputDevice', autospec=True) @mock.patch.object(usb.core, 'find', autospec=True) def test_monitor_device_thread_library_calls(self, usb_core_find_mock, input_device_mock, load_keycodes_from_file_mock): """Tests if all the calls to the libraries are made.""" vendor_id = int(self.pyudev_device.ID_VENDOR_ID, 16) product_id = int(self.pyudev_device.ID_MODEL_ID, 16) ukip.monitor_device_thread(self.pyudev_device, vendor_id, product_id) load_keycodes_from_file_mock.assert_called() input_device_mock.assert_called_once_with(self.pyudev_device.device_node) usb_core_find_mock.assert_called_once_with( idVendor=vendor_id, idProduct=product_id) def test_monitor_device_thread_logging(self): """Tests the initial logging of the thread starting function.""" # TODO Implement this test. @mock.patch.object(ukip, 'load_keycodes_from_file', autospec=True) @mock.patch.object(ukip, 'log', autospec=True) def test_monitor_device_thread_exception_inputdevice( self, logging_mock, load_keycodes_from_file_mock): """Tests exception and log message for the InputDevice creation.""" log_message = ('There was an error while starting the thread for device ' 'monitoring: %s') exception_message = '[Errno 19] No such device' exception_object = OSError(exception_message) evdev.InputDevice.side_effect = exception_object vendor_id = int(self.pyudev_device.ID_VENDOR_ID, 16) product_id = int(self.pyudev_device.ID_MODEL_ID, 16) ukip.monitor_device_thread(self.pyudev_device, vendor_id, product_id) load_keycodes_from_file_mock.assert_called() logging_mock.warning.assert_called() @mock.patch.object(ukip, 'load_keycodes_from_file', autospec=True) @mock.patch.object(ukip, 'log', autospec=True) def test_monitor_device_thread_exception_read_loop( self, logging_mock, load_keycodes_from_file_mock): """Tests exception and log message in read_loop.""" log_message = 'Events found for unbound device: %s' exception_message = '[Errno 19] No such device' exception_object = OSError(exception_message) local_mock_inputdevice = mock.MagicMock() evdev.InputDevice.return_value = local_mock_inputdevice local_mock_inputdevice.read_loop.side_effect = exception_object vendor_id = int(self.pyudev_device.ID_VENDOR_ID, 16) product_id = int(self.pyudev_device.ID_MODEL_ID, 16) ukip.monitor_device_thread(self.pyudev_device, vendor_id, product_id) load_keycodes_from_file_mock.assert_called() logging_mock.warning.assert_called() def test_monitor_device_thread_keystroke_in_ms(self): """Tests if add_to_ringbuffer was called with the keystroke time in ms.""" # TODO Implement this test. def test_monitor_device_thread_keystroke_shift(self): """Tests if add_to_ringbuffer was called with the upper case keystroke.""" # TODO Implement this test. def test_monitor_device_thread_keystroke_capslock(self): """Tests if add_to_ringbuffer was called with the upper case keystroke.""" # TODO Implement this test. @mock.patch.object(pyudev, 'Context', autospec=True) @mock.patch.object(pyudev.Monitor, 'from_netlink', autospec=True) def test_init_device_list_library_calls(self, netlink_mock, context_mock): """Tests if the initial library calls are made.""" ukip.init_device_list() self.assertEqual(context_mock.call_count, 1) self.assertEqual(netlink_mock.call_count, 1) def test_init_device_list_exceptions(self): """Tests if exceptions were raised (ValueError and DeviceError).""" # TODO Implement this test. def test_init_device_list_device_count(self): """Tests if the number of devices is increased when iterating.""" # TODO Implement this test. def test_init_device_list_invalid_pid_vid(self): """Tests if a ValueError is raised, when the VID/PID cannot be converted.""" # TODO Implement this test. def test_init_device_list_runtimeerror(self): """Tests if the RuntimeError is thrown, when the thread failed to start.""" # TODO Implement this test. def test_main_threading(self): """Tests if the thread was started.""" # TODO Implement this test. def test_main_too_many_arguments(self): """Tests if no arguments were provided to main.""" # TODO Implement this test. @mock.patch.object(pyudev.Monitor, 'from_netlink', autospec=True) def test_main_filter_by(self, netlink_mock): """Tests if the monitor filter_by was actually called.""" monitor_mock = mock.MagicMock() pyudev.Monitor.from_netlink.return_value = monitor_mock monitor_mock.poll.side_effect = [self.pyudev_device, None] netlink_mock.return_value = monitor_mock ukip.main(['ukip.py']) calls = [mock.call(subsystem='input'), mock.call(subsystem='input')] monitor_mock.filter_by.assert_has_calls(calls) @mock.patch.object(builtins, 'open', autospec=True) def test_check_local_allowlist(self, open_mock): """Tests if the local allowlist check returns the allowlist on success.""" open_mock.return_value.__enter__ = open_mock # Prepare a fake file, that looks similar to the actual file. open_mock.return_value.__iter__.return_value = iter([ '# This is the config file\n', '# for UKIP.\n', '0x3784:0x3472 a,b,c\n' ]) # Call with a PID and VID that will be found. allowlist = ukip.check_local_allowlist('0x3784', '0x3472') # If the PID and VID are found, the function returns the allowlist. self.assertEqual( allowlist, ukip.AllowlistConfigReturn( allowlist=['a', 'b', 'c'], device_present=True)) @mock.patch.object(builtins, 'open', autospec=True) def test_check_local_allowlist_two_devices(self, open_mock): """Tests if the local allowlist with two devices, where one matches.""" open_mock.return_value.__enter__ = open_mock # Prepare a fake file, that looks similar to the actual file. open_mock.return_value.__iter__.return_value = iter([ '# This is the config file\n', '# for UKIP.\n', '0x1337:0x1234 x,y,z\n', '0x3784:0x3472 a,b,c\n' ]) # Call with a PID and VID that will be found. allowlist = ukip.check_local_allowlist('0x3784', '0x3472') # If the PID and VID are found, the function returns the allowlist. self.assertEqual( allowlist, ukip.AllowlistConfigReturn( allowlist=['a', 'b', 'c'], device_present=True)) @mock.patch.object(builtins, 'open', autospec=True) def test_check_local_allowlist_only_comments(self, open_mock): """Tests if the local allowlist check returns False when only comments.""" open_mock.return_value.__enter__ = open_mock # Prepare a fake file, with only comments. open_mock.return_value.__iter__.return_value = iter([ '# This is the config file\n', '# for UKIP.\n', '# One more comment line.\n' ]) # Lookup for a PID and VID. allowlist = ukip.check_local_allowlist('0x3784', '0x3472') # If there are only comment in the config file, return False. self.assertEqual( allowlist, ukip.AllowlistConfigReturn(allowlist=[], device_present=False)) @mock.patch.object(builtins, 'open', autospec=True) def test_check_local_allowlist_no_device(self, open_mock): """Tests if the allowlist check returns False when device not in file.""" open_mock.return_value.__enter__ = open_mock open_mock.return_value.__iter__.return_value = iter([ '# This is the config file\n', '# for UKIP.\n', '0x3784:0x3472 a,b,c\n' ]) # Lookup for a PID and VID which are not in the config file. allowlist = ukip.check_local_allowlist('0x1234', '0x3472') # If the device cannot be found in the config file, return False. self.assertEqual( allowlist, ukip.AllowlistConfigReturn(allowlist=[], device_present=False)) @mock.patch.object(builtins, 'open', autospec=True) def test_check_local_allowlist_key_val_parsing(self, open_mock): """Tests if the config file could be parsed into keys and values.""" open_mock.return_value.__enter__ = open_mock open_mock.return_value.__iter__.return_value = iter([ '# This is the config file\n', '# for UKIP.\n', 'cannotparse\n' ]) # Check if the exception was raised. self.assertRaises(ukip.AllowlistFileError, ukip.check_local_allowlist, '0x1234', '0x3472') @mock.patch.object(builtins, 'open', autospec=True) def test_check_local_allowlist_device_parsing(self, open_mock): """Tests if the device in the config file can be parsed.""" open_mock.return_value.__enter__ = open_mock open_mock.return_value.__iter__.return_value = iter([ '# This is the config file\n', '# for UKIP.\n', '37843472 a,b,c\n' ]) self.assertRaises(ukip.AllowlistFileError, ukip.check_local_allowlist, '0x3784', '0x3472') @mock.patch.object(builtins, 'open', autospec=True) def test_check_local_allowlist_parsing(self, open_mock): """Tests if allowlist could be parsed from the config file.""" open_mock.return_value.__enter__ = open_mock open_mock.return_value.__iter__.return_value = iter([ '# This is the config file\n', '# for UKIP.\n', '0x3784:0x3472 cannotparse\n' ]) # The device will be found, but the allowlist cannot be parsed. allowlist = ukip.check_local_allowlist('0x3784', '0x3472') # If the allowlist is a word, that is not 'any' or 'none', return False. self.assertEqual( allowlist, ukip.AllowlistConfigReturn(allowlist=[], device_present=False)) @mock.patch.object(builtins, 'open', autospec=True) def test_check_local_allowlist_file_not_found(self, open_mock): """Tests if the config file could be found.""" open_mock.side_effect = ukip.AllowlistFileError( 'The config file /etc/ukip/allowlist could not be found: %s') self.assertRaises(ukip.AllowlistFileError, ukip.check_local_allowlist, '0x3784', '0x3472') @mock.patch.object(builtins, 'open', autospec=True) def test_check_local_allowlist_empty_lines(self, open_mock): """Tests if the allowlist check returns False when only empty lines.""" open_mock.return_value.__enter__ = open_mock # Prepare a fake file, with only empty lines. open_mock.return_value.__iter__.return_value = iter( ['\n', ' \n', ' \n']) # Lookup for a PID and VID. allowlist = ukip.check_local_allowlist('0x3784', '0x3472') # If there are only empty lines in the config file, return False. self.assertEqual( allowlist, ukip.AllowlistConfigReturn(allowlist=[], device_present=False)) @mock.patch.object(builtins, 'open', autospec=True) def test_check_local_allowlist_allow_all(self, open_mock): """Tests if the allowlist check returns True for "allow all characters".""" open_mock.return_value.__enter__ = open_mock # Prepare a fake file, with only empty lines. open_mock.return_value.__iter__.return_value = iter([ '0x1234:0x1337 any\n', ]) # Lookup for a PID and VID. allowlist = ukip.check_local_allowlist('0x1234', '0x1337') # If all possible characters are allowed for a device, return an empty list # and True. self.assertEqual( allowlist, ukip.AllowlistConfigReturn(allowlist=[], device_present=True)) @mock.patch.object(builtins, 'open', autospec=True) def test_check_local_allowlist_deny_all(self, open_mock): """Tests if the allowlist is an empty list when denying all characters.""" open_mock.return_value.__enter__ = open_mock # Prepare a fake file, with only empty lines. open_mock.return_value.__iter__.return_value = iter([ '0x1234:0x1337 none\n', ]) # Lookup for a PID and VID. allowlist = ukip.check_local_allowlist('0x1234', '0x1337') # If no characters are allowed for the given device, return an empty list. self.assertEqual( allowlist, ukip.AllowlistConfigReturn(allowlist=[], device_present=False)) def fill_test_ringbuffer_with_data(self): """A helper function to add times and trigger the hardening mode.""" ukip.add_to_ring_buffer(self.event_device_path, 1555146977759524, 'a', self.mock_pyusb_device) ukip.add_to_ring_buffer(self.event_device_path, 1555146977859525, 'b', self.mock_pyusb_device) ukip.add_to_ring_buffer(self.event_device_path, 1555146977959526, 'c', self.mock_pyusb_device) ukip.add_to_ring_buffer(self.event_device_path, 1555146977959527, 'd', self.mock_pyusb_device) ukip.add_to_ring_buffer(self.event_device_path, 1555146977959528, 'e', self.mock_pyusb_device) @mock.patch.object(gc, 'collect', wraps=gc.collect) @mock.patch.object(ukip, 'check_local_allowlist', autospec=True) @mock.patch.object(ukip, 'log', autospec=True) def test_enforce_hardening_mode_with_product(self, logging_mock, check_allowlist_mock, gc_mock): """Tests which logging message is emitted when device has a product set.""" self.fill_test_ringbuffer_with_data() self.mock_pyusb_device.__iter__.return_value = iter([self.mock_usb_config]) # Need a link, because after the function is run, the dicts are deleted. timings = ukip._event_devices_timings[self.event_device_path] # Return the allowlist from /etc/ukip/allowlist. check_allowlist_mock.return_value = ukip.AllowlistConfigReturn( allowlist=['a', 'b', 'c'], device_present=True) ukip.enforce_hardening_mode(self.mock_pyusb_device, self.event_device_path) check_allowlist_mock.assert_called_once_with( hex(self.mock_pyusb_device.idProduct), hex(self.mock_pyusb_device.idVendor)) # Only 1 interface, so the range is 0. self.mock_pyusb_device.detach_kernel_driver.assert_called_once_with(0) logging_mock.warning.assert_called_with( '[UKIP] The device %s with the vendor id %s and the product id %s ' 'was blocked. The causing timings were: %s.', self.mock_pyusb_device.product, hex(self.mock_pyusb_device.idVendor), hex(self.mock_pyusb_device.idProduct), timings) # The error was not logged. self.assertFalse(logging_mock.error.called) # The dicts are deleted now. self.assertFalse(ukip._event_devices_timings) self.assertFalse(ukip._event_devices_keystrokes) # And the garbage collector ran. gc_mock.assert_called_once() @mock.patch.object(gc, 'collect', wraps=gc.collect) @mock.patch.object(ukip, 'check_local_allowlist', autospec=True) @mock.patch.object(ukip, 'log', autospec=True) def test_enforce_hardening_mode_no_product(self, logging_mock, check_allowlist_mock, gc_mock): """Tests which logging message is emitted when device has no product set.""" self.fill_test_ringbuffer_with_data() self.mock_pyusb_device.__iter__.return_value = iter([self.mock_usb_config]) self.mock_pyusb_device.product = None # Need a link, because after the function is run, the dicts are deleted. timings = ukip._event_devices_timings[self.event_device_path] # Return the allowlist from /etc/ukip/allowlist. check_allowlist_mock.return_value = ukip.AllowlistConfigReturn( allowlist=['a', 'b', 'c'], device_present=True) ukip.enforce_hardening_mode(self.mock_pyusb_device, self.event_device_path) check_allowlist_mock.assert_called_once_with( hex(self.mock_pyusb_device.idProduct), hex(self.mock_pyusb_device.idVendor)) # Only 1 interface, so the range is 0. self.mock_pyusb_device.detach_kernel_driver.assert_called_once_with(0) logging_mock.warning.assert_called_with( '[UKIP] The device with the vendor id %s and the product id %s was ' 'blocked. The causing timings were: %s.', hex(self.mock_pyusb_device.idVendor), hex(self.mock_pyusb_device.idProduct), timings) self.assertFalse(logging_mock.error.called) # The dicts are deleted now. self.assertFalse(ukip._event_devices_timings) self.assertFalse(ukip._event_devices_keystrokes) # And the garbage collector ran. gc_mock.assert_called_once() @mock.patch.object(ukip, 'check_local_allowlist', autospec=True) @mock.patch.object(ukip, 'log', autospec=True) def test_enforce_hardening_mode_no_active_driver(self, logging_mock, check_allowlist_mock): """Tests flow through function when no interface has an active driver.""" self.fill_test_ringbuffer_with_data() self.mock_pyusb_device.__iter__.return_value = iter([self.mock_usb_config]) self.mock_pyusb_device.is_kernel_driver_active.return_value = False # Return the allowlist from /etc/ukip/allowlist. check_allowlist_mock.return_value = ukip.AllowlistConfigReturn( allowlist=['a', 'b', 'c'], device_present=True) ukip.enforce_hardening_mode(self.mock_pyusb_device, self.event_device_path) check_allowlist_mock.assert_called_once_with( hex(self.mock_pyusb_device.idProduct), hex(self.mock_pyusb_device.idVendor)) self.assertFalse(self.mock_pyusb_device.detach_kernel_driver.called) self.assertFalse(logging_mock.warning.called) self.assertFalse(logging_mock.error.called) @mock.patch.object(ukip, 'check_local_allowlist', autospec=True) @mock.patch.object(ukip, 'log', autospec=True) def test_enforce_hardening_mode_ioerror(self, logging_mock, check_allowlist_mock): """Tests IOError/log message for unbinding a driver from an interface.""" self.fill_test_ringbuffer_with_data() log_message = ('There was an error in unbinding the interface for the USB ' 'device %s: %s') exception_message = '[Errno 16] Device or resource busy' exception_object = IOError(exception_message) product_id = hex(self.mock_pyusb_device.idProduct) vendor_id = hex(self.mock_pyusb_device.idVendor) pid_and_vid = '%s:%s' % (product_id, vendor_id) self.mock_pyusb_device.__iter__.return_value = iter([self.mock_usb_config]) self.mock_pyusb_device.detach_kernel_driver.side_effect = exception_object # Return the allowlist from /etc/ukip/allowlist. check_allowlist_mock.return_value = ukip.AllowlistConfigReturn( allowlist=['a', 'b', 'c'], device_present=True) ukip.enforce_hardening_mode(self.mock_pyusb_device, self.event_device_path) check_allowlist_mock.assert_called_once_with( hex(self.mock_pyusb_device.idProduct), hex(self.mock_pyusb_device.idVendor)) logging_mock.warning.assert_called() @mock.patch.object(gc, 'collect', wraps=gc.collect) @mock.patch.object(ukip, 'check_local_allowlist', autospec=True) @mock.patch.object(ukip, 'log', autospec=True) def test_enforce_hardening_mode_multiple_interfaces_error( self, logging_mock, check_allowlist_mock, gc_mock): """Tests multiple interfaces, with one failing with an IOError.""" self.fill_test_ringbuffer_with_data() log_message = ('There was an error in unbinding the interface for the USB ' 'device %s: %s') exception_message = '[Errno 16] Device or resource busy' exception_object = IOError(exception_message) product_id = hex(self.mock_pyusb_device.idProduct) vendor_id = hex(self.mock_pyusb_device.idVendor) pid_and_vid = '%s:%s' % (product_id, vendor_id) self.mock_pyusb_device.__iter__.return_value = iter([self.mock_usb_config]) self.mock_usb_config.bNumInterfaces = 2 self.mock_pyusb_device.detach_kernel_driver.side_effect = [ exception_object, mock.DEFAULT ] # Need a link, because after the function is run, the dicts are deleted. timings = ukip._event_devices_timings[self.event_device_path] # Return the allowlist from /etc/ukip/allowlist. check_allowlist_mock.return_value = ukip.AllowlistConfigReturn( allowlist=['a', 'b', 'c'], device_present=True) ukip.enforce_hardening_mode(self.mock_pyusb_device, self.event_device_path) check_allowlist_mock.assert_called_once_with( hex(self.mock_pyusb_device.idProduct), hex(self.mock_pyusb_device.idVendor)) call = [ mock.call( '[UKIP] The device %s with the vendor id %s and the product id ' '%s was blocked. The causing timings were: %s.', self.mock_pyusb_device.product, hex(self.mock_pyusb_device.idVendor), hex(self.mock_pyusb_device.idProduct), timings) ] logging_mock.warning.assert_has_calls(call) # The dicts are deleted now. self.assertFalse(ukip._event_devices_timings) self.assertFalse(ukip._event_devices_keystrokes) # And the garbage collector ran. gc_mock.assert_called_once() @mock.patch.object(ukip, 'check_local_allowlist', autospec=True) @mock.patch.object(ukip, 'log', autospec=True) def test_enforce_hardening_mode_oserror(self, logging_mock, check_allowlist_mock): """Tests OSError/log message for unbinding a driver from an interface.""" self.fill_test_ringbuffer_with_data() log_message = ('There was an error in unbinding the interface for the USB ' 'device %s: %s') exception_message = 'access violation' exception_object = OSError(exception_message) product_id = hex(self.mock_pyusb_device.idProduct) vendor_id = hex(self.mock_pyusb_device.idVendor) pid_and_vid = '%s:%s' % (product_id, vendor_id) self.mock_pyusb_device.__iter__.return_value = iter([self.mock_usb_config]) self.mock_pyusb_device.detach_kernel_driver.side_effect = exception_object # Return the allowlist from /etc/ukip/allowlist. check_allowlist_mock.return_value = ukip.AllowlistConfigReturn( allowlist=['a', 'b', 'c'], device_present=True) ukip.enforce_hardening_mode(self.mock_pyusb_device, self.event_device_path) check_allowlist_mock.assert_called_once_with( hex(self.mock_pyusb_device.idProduct), hex(self.mock_pyusb_device.idVendor)) logging_mock.warning.assert_called() @mock.patch.object(ukip, 'check_local_allowlist', autospec=True) @mock.patch.object(ukip, 'log', autospec=True) def test_enforce_hardening_mode_valueerror(self, logging_mock, check_allowlist_mock): """Tests ValueError/log message for unbinding a driver from an interface.""" self.fill_test_ringbuffer_with_data() log_message = ('There was an error in unbinding the interface for the USB ' 'device %s: %s') exception_message = 'Invalid configuration' exception_object = ValueError(exception_message) product_id = hex(self.mock_pyusb_device.idProduct) vendor_id = hex(self.mock_pyusb_device.idVendor) pid_and_vid = '%s:%s' % (product_id, vendor_id) self.mock_pyusb_device.__iter__.return_value = iter([self.mock_usb_config]) self.mock_pyusb_device.detach_kernel_driver.side_effect = exception_object # Return the allowlist from /etc/ukip/allowlist. check_allowlist_mock.return_value = ukip.AllowlistConfigReturn( allowlist=['a', 'b', 'c'], device_present=True) ukip.enforce_hardening_mode(self.mock_pyusb_device, self.event_device_path) check_allowlist_mock.assert_called_once_with( hex(self.mock_pyusb_device.idProduct), hex(self.mock_pyusb_device.idVendor)) logging_mock.warning.assert_called() @mock.patch.object(ukip, 'check_local_allowlist', autospec=True) @mock.patch.object(ukip, 'log', autospec=True) def test_enforce_hardening_mode_usberror(self, logging_mock, check_allowlist_mock): """Tests USBError/log message for unbinding a driver from an interface.""" self.fill_test_ringbuffer_with_data() log_message = ('There was an error in unbinding the interface for the USB ' 'device %s: %s') exception_message = 'USBError Accessing Configurations' exception_object = usb.core.USBError(exception_message) product_id = hex(self.mock_pyusb_device.idProduct) vendor_id = hex(self.mock_pyusb_device.idVendor) pid_and_vid = '%s:%s' % (product_id, vendor_id) self.mock_pyusb_device.__iter__.return_value = iter([self.mock_usb_config]) self.mock_pyusb_device.detach_kernel_driver.side_effect = exception_object # Return the allowlist from /etc/ukip/allowlist. check_allowlist_mock.return_value = ukip.AllowlistConfigReturn( allowlist=['a', 'b', 'c'], device_present=True) ukip.enforce_hardening_mode(self.mock_pyusb_device, self.event_device_path) check_allowlist_mock.assert_called_once_with( hex(self.mock_pyusb_device.idProduct), hex(self.mock_pyusb_device.idVendor)) logging_mock.warning.assert_called() @mock.patch.object(gc, 'collect', wraps=gc.collect) @mock.patch.object(ukip, 'check_local_allowlist', autospec=True) @mock.patch.object(ukip, 'log', autospec=True) def test_enforce_hardening_mode_any_keyword(self, logging_mock, check_allowlist_mock, gc_mock): """Tests an early return if the any keyword is set in the allowlist.""" self.fill_test_ringbuffer_with_data() self.mock_pyusb_device.__iter__.return_value = iter([self.mock_usb_config]) # Device present and empty allowlist -> any keyword was set. check_allowlist_mock.return_value = ukip.AllowlistConfigReturn( allowlist=[], device_present=True) ukip.enforce_hardening_mode(self.mock_pyusb_device, self.event_device_path) check_allowlist_mock.assert_called_once_with( hex(self.mock_pyusb_device.idProduct), hex(self.mock_pyusb_device.idVendor)) # Due to the early return, none of the followup functions are called. self.assertFalse(self.mock_pyusb_device.detach_kernel_driver.called) self.assertFalse(logging_mock.called) self.assertFalse(gc_mock.called) @mock.patch.object(gc, 'collect', wraps=gc.collect) @mock.patch.object(ukip, 'check_local_allowlist', autospec=True) @mock.patch.object(ukip, 'log', autospec=True) def test_enforce_hardening_mode_keystrokes_allowed(self, logging_mock, check_allowlist_mock, gc_mock): """Tests an early return if the typed keys are allowed in the allowlist.""" # This sets the typed keys to [a,b,c,d,e] self.fill_test_ringbuffer_with_data() self.mock_pyusb_device.__iter__.return_value = iter([self.mock_usb_config]) # Device present and allowlist set to typed characters. check_allowlist_mock.return_value = ukip.AllowlistConfigReturn( allowlist=['a', 'b', 'c', 'd', 'e'], device_present=True) ukip.enforce_hardening_mode(self.mock_pyusb_device, self.event_device_path) check_allowlist_mock.assert_called_once_with( hex(self.mock_pyusb_device.idProduct), hex(self.mock_pyusb_device.idVendor)) # Due to the early return, none of the followup functions are called. self.assertFalse(self.mock_pyusb_device.detach_kernel_driver.called) self.assertFalse(logging_mock.called) self.assertFalse(gc_mock.called) @mock.patch.object(gc, 'collect', wraps=gc.collect) @mock.patch.object(ukip, 'check_local_allowlist', autospec=True) @mock.patch.object(ukip, 'log', autospec=True) def test_enforce_hardening_mode_keystrokes_allowed_subset( self, logging_mock, check_allowlist_mock, gc_mock): """Tests an early return with a subset of allowed keys.""" ukip.add_to_ring_buffer(self.event_device_path, 1555146977759524, 'a', self.mock_pyusb_device) ukip.add_to_ring_buffer(self.event_device_path, 1555146977859525, 'b', self.mock_pyusb_device) ukip.add_to_ring_buffer(self.event_device_path, 1555146977959526, 'c', self.mock_pyusb_device) self.mock_pyusb_device.__iter__.return_value = iter([self.mock_usb_config]) # Device present and allowlist set to typed characters. check_allowlist_mock.return_value = ukip.AllowlistConfigReturn( allowlist=['a', 'b', 'c', 'd', 'e', 'f'], device_present=True) ukip.enforce_hardening_mode(self.mock_pyusb_device, self.event_device_path) check_allowlist_mock.assert_called_once_with( hex(self.mock_pyusb_device.idProduct), hex(self.mock_pyusb_device.idVendor)) # Due to the early return, none of the followup functions are called. self.assertFalse(self.mock_pyusb_device.detach_kernel_driver.called) self.assertFalse(logging_mock.called) self.assertFalse(gc_mock.called) @mock.patch.object(gc, 'collect', wraps=gc.collect) @mock.patch.object(ukip, 'check_local_allowlist', autospec=True) @mock.patch.object(ukip, 'log', autospec=True) def test_enforce_hardening_mode_device_not_present(self, logging_mock, check_allowlist_mock, gc_mock): """Tests function flow when the device is not present in the allowlist.""" self.fill_test_ringbuffer_with_data() self.mock_pyusb_device.__iter__.return_value = iter([self.mock_usb_config]) # Need a link, because after the function is run, the dicts are deleted. timings = ukip._event_devices_timings[self.event_device_path] # Return the allowlist from /etc/ukip/allowlist. check_allowlist_mock.return_value = ukip.AllowlistConfigReturn( allowlist=[], device_present=False) ukip.enforce_hardening_mode(self.mock_pyusb_device, self.event_device_path) check_allowlist_mock.assert_called_once_with( hex(self.mock_pyusb_device.idProduct), hex(self.mock_pyusb_device.idVendor)) # Only 1 interface, so the range is 0. self.mock_pyusb_device.detach_kernel_driver.assert_called_once_with(0) logging_mock.warning.assert_called_with( '[UKIP] The device %s with the vendor id %s and the product id %s ' 'was blocked. The causing timings were: %s.', self.mock_pyusb_device.product, hex(self.mock_pyusb_device.idVendor), hex(self.mock_pyusb_device.idProduct), timings) # The error was not logged. self.assertFalse(logging_mock.error.called) # The dicts are deleted now. self.assertFalse(ukip._event_devices_timings) self.assertFalse(ukip._event_devices_keystrokes) # And the garbage collector ran. gc_mock.assert_called_once() @mock.patch.object(gc, 'collect', wraps=gc.collect) @mock.patch.object(ukip, 'check_local_allowlist', autospec=True) @mock.patch.object(ukip, 'log', autospec=True) def test_enforce_hardening_mode_one_key_off(self, logging_mock, check_allowlist_mock, gc_mock): """Tests the hardening mode when one typed key is not allowed.""" # This sets the typed keys to [a,b,c,d,e] self.fill_test_ringbuffer_with_data() self.mock_pyusb_device.__iter__.return_value = iter([self.mock_usb_config]) # Need a link, because after the function is run, the dicts are deleted. timings = ukip._event_devices_timings[self.event_device_path] # Return the allowlist from /etc/ukip/allowlist. The 'e' from the typed # keys is not allowed. check_allowlist_mock.return_value = ukip.AllowlistConfigReturn( allowlist=['a', 'b', 'c', 'd', 'f'], device_present=False) ukip.enforce_hardening_mode(self.mock_pyusb_device, self.event_device_path) check_allowlist_mock.assert_called_once_with( hex(self.mock_pyusb_device.idProduct), hex(self.mock_pyusb_device.idVendor)) # Only 1 interface, so the range is 0. self.mock_pyusb_device.detach_kernel_driver.assert_called_once_with(0) logging_mock.warning.assert_called_with( '[UKIP] The device %s with the vendor id %s and the product id %s ' 'was blocked. The causing timings were: %s.', self.mock_pyusb_device.product, hex(self.mock_pyusb_device.idVendor), hex(self.mock_pyusb_device.idProduct), timings) # The error was not logged. self.assertFalse(logging_mock.error.called) # The dicts are deleted now. self.assertFalse(ukip._event_devices_timings) self.assertFalse(ukip._event_devices_keystrokes) # And the garbage collector ran. gc_mock.assert_called_once() @mock.patch.object(ukip, 'log', autospec=True) @mock.patch.object(builtins, 'open') def test_load_keycodes_from_file(self, open_mock, logging_mock): """Tests if the keycode file returns the KeycodesReturn class.""" handle = open_mock().__enter__.return_value keycode_file_content = [{ 'lowcodes': [{ '1': 'ESC', '2': '1' }], 'capscodes': [{ '1': 'ESC', '2': '!' }] }] file_mock = mock.MagicMock(side_effect=keycode_file_content) json_mock = mock.patch('json.load', file_mock) with open_mock: with json_mock as json_load_mock: keycodes = ukip.load_keycodes_from_file() json_load_mock.assert_called_with(handle) self.assertEqual(keycodes.lower_codes, {1: 'ESC', 2: '1'}) self.assertEqual(keycodes.capped_codes, {1: 'ESC', 2: '!'}) logging_mock.assert_not_called() @mock.patch.object(ukip, 'log', autospec=True) @mock.patch.object(builtins, 'open') def test_load_keycodes_from_file_missing_keyword(self, open_mock, logging_mock): """Tests the keycode file returns when a keyword is missing.""" handle = open_mock().__enter__.return_value keycode_file_content = [{ 'not_low_codes': [{ '1': 'ESC', '2': '1' }], 'capscodes': [{ '1': 'ESC', '2': '!' }] }] file_mock = mock.MagicMock(side_effect=keycode_file_content) json_mock = mock.patch('json.load', file_mock) with open_mock: with json_mock as json_load_mock: keycodes = ukip.load_keycodes_from_file() json_load_mock.assert_called_with(handle) # The lowcodes keyword is missing in the keycodes file. self.assertEqual(keycodes.lower_codes, {}) self.assertEqual(keycodes.capped_codes, {}) logging_mock.error.assert_called() @mock.patch.object(ukip, 'log', autospec=True) @mock.patch.object(json, 'load', autospec=True) @mock.patch.object(builtins, 'open', autospec=True) def test_load_keycodes_from_file_overflowerror(self, open_mock, json_mock, logging_mock): """Tests if KeycodesFileError is raised on an OverflowError.""" json_mock.side_effect = OverflowError self.assertRaises(ukip.KeycodesFileError, ukip.load_keycodes_from_file) open_mock.assert_called() json_mock.assert_called() logging_mock.assert_not_called() @mock.patch.object(ukip, 'log', autospec=True) @mock.patch.object(json, 'load', autospec=True) @mock.patch.object(builtins, 'open', autospec=True) def test_load_keycodes_from_file_valueerror(self, open_mock, json_mock, logging_mock): """Tests if KeycodesFileError is raised on a ValueError.""" json_mock.side_effect = ValueError self.assertRaises(ukip.KeycodesFileError, ukip.load_keycodes_from_file) open_mock.assert_called() json_mock.assert_called() logging_mock.assert_not_called() @mock.patch.object(ukip, 'log', autospec=True) @mock.patch.object(json, 'load', autospec=True) @mock.patch.object(builtins, 'open', autospec=True) def test_load_keycodes_from_file_typeerror(self, open_mock, json_mock, logging_mock): """Tests if KeycodesFileError is raised on a TypeError.""" json_mock.side_effect = TypeError self.assertRaises(ukip.KeycodesFileError, ukip.load_keycodes_from_file) open_mock.assert_called() json_mock.assert_called() logging_mock.assert_not_called() @mock.patch.object(ukip, 'log', autospec=True) @mock.patch.object(json, 'load', autospec=True) @mock.patch.object(builtins, 'open', autospec=True) def test_load_keycodes_from_file_not_found(self, open_mock, json_mock, logging_mock): """Tests if KeycodesFileError is raised on a FileNotFoundError.""" json_mock.side_effect = FileNotFoundError self.assertRaises(ukip.KeycodesFileError, ukip.load_keycodes_from_file) open_mock.assert_called() json_mock.assert_called() logging_mock.assert_not_called() if __name__ == '__main__': unittest.main()